Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/app/interfaces/user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,14 @@ def delete_user_by_email(self, email):
:raises Exception: if user deletion fails
"""
pass

@abstractmethod
def get_admins(self):
"""
Get all admin users

:return: list of UserDTOs for admin users
:rtype: [UserDTO]
:raises Exception: if user retrieval fails
"""
pass
1 change: 0 additions & 1 deletion backend/app/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from ..utilities.constants import LOGGER_NAME
from ..utilities.service_utils import get_auth_service
from ..schemas.user import UserRole

security = HTTPBearer()
logger = logging.getLogger(LOGGER_NAME("auth"))
Expand Down
3 changes: 2 additions & 1 deletion backend/app/models/User.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import uuid

from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship

Expand All @@ -15,5 +15,6 @@ class User(Base):
email = Column(String(120), unique=True, nullable=False)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
auth_id = Column(String, nullable=False)
approved = Column(Boolean, default=False)

role = relationship("Role")
3 changes: 1 addition & 2 deletions backend/app/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from ..middleware.auth import UserRole
from ..schemas.auth import AuthResponse, LoginRequest, RefreshRequest, Token
from ..schemas.user import UserCreateRequest, UserCreateResponse
from ..schemas.user import UserCreateRequest, UserCreateResponse, UserRole
from ..services.implementations.auth_service import AuthService
from ..services.implementations.user_service import UserService
from ..utilities.service_utils import get_auth_service, get_user_service
Expand Down
81 changes: 79 additions & 2 deletions backend/app/routes/user.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Query

from app.middleware.auth import has_roles
from app.schemas.user import UserCreateRequest, UserCreateResponse, UserRole
from app.schemas.user import (
UserCreateRequest,
UserCreateResponse,
UserListResponse,
UserResponse,
UserRole,
UserUpdateRequest,
)
from app.services.implementations.user_service import UserService
from app.utilities.service_utils import get_user_service

Expand All @@ -28,3 +37,71 @@ async def create_user(
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


# admin only get all users
@router.get("/", response_model=UserListResponse)
async def get_users(
admin: Optional[bool] = Query(
False, description="If true, returns admin users only"
),
user_service: UserService = Depends(get_user_service),
authorized: bool = has_roles([UserRole.ADMIN]),
):
try:
if admin:
users = await user_service.get_admins()
else:
users = await user_service.get_users()
return UserListResponse(users=users, total=len(users))
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


# admin only get user by ID
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: str,
user_service: UserService = Depends(get_user_service),
authorized: bool = has_roles([UserRole.ADMIN]),
):
try:
return await user_service.get_user_by_id(user_id)
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


# admin only update user (mainly for approvals)
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: str,
user_update: UserUpdateRequest,
user_service: UserService = Depends(get_user_service),
authorized: bool = has_roles([UserRole.ADMIN]),
):
try:
return await user_service.update_user_by_id(user_id, user_update)
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


# admin only delete user
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
user_service: UserService = Depends(get_user_service),
authorized: bool = has_roles([UserRole.ADMIN]),
):
try:
await user_service.delete_user_by_id(user_id)
return {"message": "User deleted successfully"}
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
52 changes: 51 additions & 1 deletion backend/app/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"""

from enum import Enum
from typing import Optional
from typing import List, Optional
from uuid import UUID

from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
Expand Down Expand Up @@ -73,6 +73,18 @@ def validate_password(cls, password: Optional[str], info):
return password


class UserUpdateRequest(BaseModel):
"""
Request schema for user updates, all fields optional
"""

first_name: Optional[str] = Field(None, min_length=1, max_length=50)
last_name: Optional[str] = Field(None, min_length=1, max_length=50)
email: Optional[EmailStr] = None
role: Optional[UserRole] = None
approved: Optional[bool] = None


class UserCreateResponse(BaseModel):
"""
Response schema for user creation, maps directly from ORM User object.
Expand All @@ -84,6 +96,44 @@ class UserCreateResponse(BaseModel):
email: EmailStr
role_id: int
auth_id: str
approved: bool

# from_attributes enables automatic mapping from SQLAlchemy model to Pydantic model
model_config = ConfigDict(from_attributes=True)


class UserResponse(BaseModel):
"""
Response schema for user data including role information
"""

id: UUID
first_name: str
last_name: str
email: EmailStr
role_id: int
auth_id: str
approved: bool
role: "RoleResponse"

model_config = ConfigDict(from_attributes=True)


class RoleResponse(BaseModel):
"""
Response schema for role data
"""

id: int
name: str

model_config = ConfigDict(from_attributes=True)


class UserListResponse(BaseModel):
"""
Response schema for listing users
"""

users: List[UserResponse]
total: int
117 changes: 106 additions & 11 deletions backend/app/services/implementations/user_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import logging
from typing import List
from uuid import UUID

import firebase_admin.auth
from fastapi import HTTPException
Expand All @@ -10,7 +12,9 @@
SignUpMethod,
UserCreateRequest,
UserCreateResponse,
UserResponse,
UserRole,
UserUpdateRequest,
)
from app.utilities.constants import LOGGER_NAME

Expand Down Expand Up @@ -78,13 +82,41 @@ async def create_user(self, user: UserCreateRequest) -> UserCreateResponse:

raise HTTPException(status_code=500, detail=str(e))

def delete_user_by_email(self, email: str):
pass
async def delete_user_by_email(self, email: str):
try:
db_user = self.db.query(User).filter(User.email == email).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")

self.db.delete(db_user)
self.db.commit()

except HTTPException:
raise
except Exception as e:
self.db.rollback()
self.logger.error(f"Error deleting user with email {email}: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

def delete_user_by_id(self, user_id: str):
pass
async def delete_user_by_id(self, user_id: str):
try:
db_user = self.db.query(User).filter(User.id == UUID(user_id)).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")

self.db.delete(db_user)
self.db.commit()

def get_user_id_by_auth_id(self, auth_id: str) -> str:
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID format")
except HTTPException:
raise
except Exception as e:
self.db.rollback()
self.logger.error(f"Error deleting user {user_id}: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

async def get_user_id_by_auth_id(self, auth_id: str) -> str:
"""Get user ID for a user by their Firebase auth_id"""
user = self.db.query(User).filter(User.auth_id == auth_id).first()
if not user:
Expand All @@ -97,8 +129,21 @@ def get_user_by_email(self, email: str):
raise ValueError(f"User with email {email} not found")
return user

def get_user_by_id(self, user_id: str):
pass
async def get_user_by_id(self, user_id: str) -> UserResponse:
try:
user = (
self.db.query(User).join(Role).filter(User.id == UUID(user_id)).first()
)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return UserResponse.model_validate(user)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID format")
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error retrieving user {user_id}: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

def get_auth_id_by_user_id(self, user_id: str) -> str:
"""Get Firebase auth_id for a user"""
Expand All @@ -114,8 +159,58 @@ def get_user_role_by_auth_id(self, auth_id: str) -> str:
raise ValueError(f"User with auth_id {auth_id} not found")
return user.role.name

def get_users(self):
pass
async def get_users(self) -> List[UserResponse]:
try:
# Filter users to only include participants and volunteers (role_id 1 and 2)
users = (
self.db.query(User).join(Role).filter(User.role_id.in_([1, 2])).all()
)
return [UserResponse.model_validate(user) for user in users]
except Exception as e:
self.logger.error(f"Error getting users: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

async def get_admins(self) -> List[UserResponse]:
try:
# Get only admin users (role_id 3)
users = self.db.query(User).join(Role).filter(User.role_id == 3).all()
return [UserResponse.model_validate(user) for user in users]
except Exception as e:
self.logger.error(f"Error retrieving admin users: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

async def update_user_by_id(
self, user_id: str, user_update: UserUpdateRequest
) -> UserResponse:
try:
db_user = self.db.query(User).filter(User.id == UUID(user_id)).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")

# update provided fields only
update_data = user_update.model_dump(exclude_unset=True)

def update_user_by_id(self, user_id: str, user):
pass
# handle role conversion if role is being updated
if "role" in update_data:
update_data["role_id"] = UserRole.to_role_id(update_data.pop("role"))

for field, value in update_data.items():
setattr(db_user, field, value)

self.db.commit()
self.db.refresh(db_user)

# return user with role information
updated_user = (
self.db.query(User).join(Role).filter(User.id == UUID(user_id)).first()
)
return UserResponse.model_validate(updated_user)

except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID format")
except HTTPException:
raise
except Exception as e:
self.db.rollback()
self.logger.error(f"Error updating user {user_id}: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
30 changes: 30 additions & 0 deletions backend/migrations/versions/8bfb115acac1_add_approved_to_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""add approved to users

Revision ID: 8bfb115acac1
Revises: c9bc2b4d1036
Create Date: 2025-06-04 16:50:38.609239

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "8bfb115acac1"
down_revision: Union[str, None] = "c9bc2b4d1036"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("users", sa.Column("approved", sa.Boolean(), nullable=True))
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users", "approved")
# ### end Alembic commands ###
Loading