diff --git a/backend/app/interfaces/user_data_service.py b/backend/app/interfaces/user_data_service.py new file mode 100644 index 00000000..ce70e31b --- /dev/null +++ b/backend/app/interfaces/user_data_service.py @@ -0,0 +1,84 @@ +from abc import ABC, abstractmethod +from uuid import UUID + +from app.schemas.user_data import UserDataCreateRequest, UserDataUpdateRequest + + +class IUserDataService(ABC): + """Interface with user data management methods.""" + + @abstractmethod + def get_user_data_by_id(self, user_data_id: UUID): + """ + Get user data by its ID + + :param user_data_id: user data's id + :type user_data_id: UUID + :return: a UserDataResponse with user data information + :rtype: UserDataResponse + :raises Exception: if user data retrieval fails + """ + pass + + @abstractmethod + def get_user_data_by_user_id(self, user_id: UUID): + """ + Get user data associated with a user ID + + :param user_id: user's id + :type user_id: UUID + :return: a UserDataResponse with user data information + :rtype: UserDataResponse + :raises Exception: if user data retrieval fails + """ + pass + + @abstractmethod + def create_user_data(self, user_data: UserDataCreateRequest): + """ + Create user data for a user + + :param user_data: the user data to be created + :type user_data: UserDataCreateRequest + :return: the created user data + :rtype: UserDataResponse + :raises Exception: if user data creation fails + """ + pass + + @abstractmethod + def update_user_data_by_user_id(self, user_id: UUID, user_data: UserDataUpdateRequest): + """ + Update user data for a user + + :param user_id: user's id + :type user_id: UUID + :param user_data: the user data to be updated + :type user_data: UserDataUpdateRequest + :return: the updated user data + :rtype: UserDataResponse + :raises Exception: if user data update fails + """ + pass + + @abstractmethod + def delete_user_data_by_id(self, user_data_id: UUID): + """ + Delete user data by its ID + + :param user_data_id: user data's id + :type user_data_id: UUID + :raises Exception: if user data deletion fails + """ + pass + + @abstractmethod + def delete_user_data_by_user_id(self, user_id: UUID): + """ + Delete user data by user ID + + :param user_id: user's id + :type user_id: UUID + :raises Exception: if user data deletion fails + """ + pass diff --git a/backend/app/models/User.py b/backend/app/models/User.py index 8ed191ee..f6a7637f 100644 --- a/backend/app/models/User.py +++ b/backend/app/models/User.py @@ -19,6 +19,7 @@ class User(Base): approved = Column(Boolean, default=False) role = relationship("Role") + user_data = relationship("UserData", back_populates="user", uselist=False) # time blocks in an availability for a user availability = relationship("TimeBlock", secondary="available_times", back_populates="users") diff --git a/backend/app/models/UserData.py b/backend/app/models/UserData.py new file mode 100644 index 00000000..2fe12ea4 --- /dev/null +++ b/backend/app/models/UserData.py @@ -0,0 +1,37 @@ +import uuid + +from sqlalchemy import Boolean, Column, Date, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from .Base import Base + + +class UserData(Base): + __tablename__ = "user_data" + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True) + date_of_birth = Column(Date, nullable=True) + email = Column(String(120), nullable=True) + phone = Column(String(20), nullable=True) + postal_code = Column(String(10), nullable=True) + province = Column(String(50), nullable=True) + city = Column(String(100), nullable=True) + language = Column(String(50), nullable=True) + criminal_record_status = Column(Boolean, nullable=True) + blood_cancer_status = Column(Boolean, nullable=True) + caregiver_status = Column(Boolean, nullable=True) + caregiver_type = Column(String(100), nullable=True) + diagnostic = Column(String(200), nullable=True) + date_of_diagnosis = Column(Date, nullable=True) + gender_identity = Column(String(50), nullable=True) + pronouns = Column(String(50), nullable=True) + ethnicity = Column(String(100), nullable=True) + marital_status = Column(String(50), nullable=True) + children_status = Column(Boolean, nullable=True) + treatment = Column(Text, nullable=True) + experience = Column(Text, nullable=True) + preferences = Column(Text, nullable=True) + + # Relationship to User table + user = relationship("User", back_populates="user_data") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e32eedcf..16d35696 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,16 +16,17 @@ from .SuggestedTime import suggested_times from .TimeBlock import TimeBlock from .User import User +from .UserData import UserData # Used to avoid import errors for the models __all__ = [ "Base", "User", "Role", + "UserData", "TimeBlock", "Match", "MatchStatus", - "User", "available_times", "suggested_times", ] diff --git a/backend/app/routes/user_data.py b/backend/app/routes/user_data.py new file mode 100644 index 00000000..683470ac --- /dev/null +++ b/backend/app/routes/user_data.py @@ -0,0 +1,111 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException + +from app.middleware.auth import has_roles +from app.schemas.user import UserRole +from app.schemas.user_data import ( + UserDataCreateRequest, + UserDataResponse, + UserDataUpdateRequest, +) +from app.services.implementations.user_data_service import UserDataService +from app.utilities.service_utils import get_user_data_service + +router = APIRouter( + prefix="/user-data", + tags=["user-data"], +) + + +@router.post("/", response_model=UserDataResponse) +async def create_user_data( + user_data: UserDataCreateRequest, + user_data_service: UserDataService = Depends(get_user_data_service), + authorized: bool = has_roles([UserRole.ADMIN, UserRole.PARTICIPANT, UserRole.VOLUNTEER]), +): + """Create user data for intake form""" + try: + return user_data_service.create_user_data(user_data) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/user/{user_id}", response_model=UserDataResponse) +async def get_user_data_by_user_id( + user_id: UUID, + user_data_service: UserDataService = Depends(get_user_data_service), + authorized: bool = has_roles([UserRole.ADMIN, UserRole.PARTICIPANT, UserRole.VOLUNTEER]), +): + """Get user data by user ID""" + try: + return user_data_service.get_user_data_by_user_id(user_id) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{user_data_id}", response_model=UserDataResponse) +async def get_user_data_by_id( + user_data_id: UUID, + user_data_service: UserDataService = Depends(get_user_data_service), + authorized: bool = has_roles([UserRole.ADMIN]), +): + """Get user data by its ID""" + try: + return user_data_service.get_user_data_by_id(user_data_id) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/user/{user_id}", response_model=UserDataResponse) +async def update_user_data_by_user_id( + user_id: UUID, + user_data: UserDataUpdateRequest, + user_data_service: UserDataService = Depends(get_user_data_service), + authorized: bool = has_roles([UserRole.ADMIN, UserRole.PARTICIPANT, UserRole.VOLUNTEER]), +): + """Update user data by user ID""" + try: + return user_data_service.update_user_data_by_user_id(user_id, user_data) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/{user_data_id}") +async def delete_user_data_by_id( + user_data_id: UUID, + user_data_service: UserDataService = Depends(get_user_data_service), + authorized: bool = has_roles([UserRole.ADMIN]), +): + """Delete user data by its ID""" + try: + user_data_service.delete_user_data_by_id(user_data_id) + return {"message": f"User data {user_data_id} deleted successfully"} + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/user/{user_id}") +async def delete_user_data_by_user_id( + user_id: UUID, + user_data_service: UserDataService = Depends(get_user_data_service), + authorized: bool = has_roles([UserRole.ADMIN]), +): + """Delete user data by user ID""" + try: + user_data_service.delete_user_data_by_user_id(user_id) + return {"message": f"User data for user {user_id} deleted successfully"} + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/schemas/user_data.py b/backend/app/schemas/user_data.py new file mode 100644 index 00000000..b778344c --- /dev/null +++ b/backend/app/schemas/user_data.py @@ -0,0 +1,68 @@ +""" +Pydantic schemas for user data validation and serialization. +Handles user data CRUD and response models for the API. +""" + +from datetime import date +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class UserDataBase(BaseModel): + """Base schema for user data with common attributes shared across schemas.""" + + date_of_birth: Optional[date] = None + email: Optional[EmailStr] = None + phone: Optional[str] = Field(None, max_length=20) + postal_code: Optional[str] = Field(None, max_length=10) + province: Optional[str] = Field(None, max_length=50) + city: Optional[str] = Field(None, max_length=100) + language: Optional[str] = Field(None, max_length=50) + criminal_record_status: Optional[bool] = None + blood_cancer_status: Optional[bool] = None + caregiver_status: Optional[bool] = None + caregiver_type: Optional[str] = Field(None, max_length=100) + diagnostic: Optional[str] = Field(None, max_length=200) + date_of_diagnosis: Optional[date] = None + gender_identity: Optional[str] = Field(None, max_length=50) + pronouns: Optional[str] = Field(None, max_length=50) + ethnicity: Optional[str] = Field(None, max_length=100) + marital_status: Optional[str] = Field(None, max_length=50) + children_status: Optional[bool] = None + treatment: Optional[str] = None + experience: Optional[str] = None + # NOTE: preferences can either be a comma-separated string or an array of strings coming from the + # client. We keep the underlying DB column as Text but allow the schema to accept both shapes so + # that the service layer can serialise the list form when necessary. + preferences: Optional[str | list[str]] = None # type: ignore[valid-type] + + +class UserDataCreateRequest(UserDataBase): + """ + Request schema for user data creation. + """ + + user_id: UUID + + +class UserDataUpdateRequest(UserDataBase): + """ + Request schema for user data update. + All fields are optional for partial updates. + """ + + pass + + +class UserDataResponse(UserDataBase): + """ + Response schema for user data, maps directly from ORM UserData object. + """ + + id: UUID + user_id: UUID + + # from_attributes enables automatic mapping from SQLAlchemy model to Pydantic model + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/server.py b/backend/app/server.py index b2775ac9..e3082b2d 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -8,7 +8,7 @@ from . import models from .middleware.auth_middleware import AuthMiddleware -from .routes import auth, availability, match, send_email, suggested_times, test, user +from .routes import auth, availability, match, send_email, suggested_times, test, user, user_data from .utilities.constants import LOGGER_NAME from .utilities.firebase_init import initialize_firebase from .utilities.ses.ses_init import ensure_ses_templates @@ -61,6 +61,7 @@ async def lifespan(_: FastAPI): app.add_middleware(AuthMiddleware, public_paths=PUBLIC_PATHS) app.include_router(auth.router) app.include_router(user.router) +app.include_router(user_data.router) app.include_router(availability.router) app.include_router(suggested_times.router) app.include_router(match.router) diff --git a/backend/app/services/implementations/user_data_service.py b/backend/app/services/implementations/user_data_service.py new file mode 100644 index 00000000..74a3d2e1 --- /dev/null +++ b/backend/app/services/implementations/user_data_service.py @@ -0,0 +1,161 @@ +import logging +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.interfaces.user_data_service import IUserDataService +from app.models import UserData +from app.schemas.user_data import ( + UserDataCreateRequest, + UserDataResponse, + UserDataUpdateRequest, +) +from app.utilities.constants import LOGGER_NAME + + +class UserDataService(IUserDataService): + def __init__(self, db: Session): + self.db = db + self.logger = logging.getLogger(LOGGER_NAME("user_data_service")) + + # --------------------------------------------------------------------- + # Internal helpers + # --------------------------------------------------------------------- + + @staticmethod + def _serialise_preferences(preferences): + """Ensure preferences are stored as a comma-separated string. + + The API allows the client to send either a list of strings **or** a + comma-separated string. This helper converts the list form into the + canonical string representation used in the database. + """ + if preferences is None: + return None + + # If the incoming value is already a string, strip extra whitespace + if isinstance(preferences, str): + # Collapse any excess whitespace around commas + return ", ".join([p.strip() for p in preferences.split(",") if p.strip()]) + + # If it is a list/tuple, join using a comma and space + if isinstance(preferences, (list, tuple)): + return ", ".join([str(p).strip() for p in preferences if str(p).strip()]) + + # Fallback – we do not expect other types, but log in case + logging.getLogger(LOGGER_NAME("user_data_service")).warning( + "Unexpected preferences data type %s – storing as string", type(preferences) + ) + return str(preferences) + + def get_user_data_by_id(self, user_data_id: UUID) -> UserDataResponse: + """Get user data by its ID""" + user_data = self.db.query(UserData).filter(UserData.id == user_data_id).first() + if not user_data: + raise HTTPException(status_code=404, detail=f"User data with id {user_data_id} not found") + return UserDataResponse.model_validate(user_data) + + def get_user_data_by_user_id(self, user_id: UUID) -> UserDataResponse: + """Get user data by user ID""" + user_data = self.db.query(UserData).filter(UserData.user_id == user_id).first() + if not user_data: + raise HTTPException(status_code=404, detail=f"User data for user {user_id} not found") + return UserDataResponse.model_validate(user_data) + + def create_user_data(self, user_data: UserDataCreateRequest) -> UserDataResponse: + """Create user data for a user""" + try: + # Check if user data already exists for this user + existing_data = self.db.query(UserData).filter(UserData.user_id == user_data.user_id).first() + if existing_data: + raise HTTPException( + status_code=409, + detail=f"User data already exists for user {user_data.user_id}", + ) + + # Prepare payload – ensure preferences field is in the correct format + data_dict = user_data.model_dump() + data_dict["preferences"] = self._serialise_preferences(data_dict.get("preferences")) + + # Create new user data + db_user_data = UserData(**data_dict) + self.db.add(db_user_data) + self.db.commit() + self.db.refresh(db_user_data) + + return UserDataResponse.model_validate(db_user_data) + + except HTTPException: + raise + except Exception as e: + self.db.rollback() + self.logger.error(f"Error creating user data: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + def update_user_data_by_user_id(self, user_id: UUID, user_data: UserDataUpdateRequest) -> UserDataResponse: + """Update user data for a user""" + try: + # Get existing user data + db_user_data = self.db.query(UserData).filter(UserData.user_id == user_id).first() + if not db_user_data: + raise HTTPException(status_code=404, detail=f"User data for user {user_id} not found") + + # Update only provided fields + update_data = user_data.model_dump(exclude_unset=True) + + # Serialise preferences if present + if "preferences" in update_data: + update_data["preferences"] = self._serialise_preferences(update_data["preferences"]) + + for key, value in update_data.items(): + setattr(db_user_data, key, value) + + self.db.commit() + self.db.refresh(db_user_data) + + return UserDataResponse.model_validate(db_user_data) + + except HTTPException: + raise + except Exception as e: + self.db.rollback() + self.logger.error(f"Error updating user data: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + def delete_user_data_by_id(self, user_data_id: UUID): + """Delete user data by its ID""" + try: + user_data = self.db.query(UserData).filter(UserData.id == user_data_id).first() + if not user_data: + raise HTTPException( + status_code=404, + detail=f"User data with id {user_data_id} not found", + ) + + self.db.delete(user_data) + self.db.commit() + + except HTTPException: + raise + except Exception as e: + self.db.rollback() + self.logger.error(f"Error deleting user data: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + def delete_user_data_by_user_id(self, user_id: UUID): + """Delete user data by user ID""" + try: + user_data = self.db.query(UserData).filter(UserData.user_id == user_id).first() + if not user_data: + raise HTTPException(status_code=404, detail=f"User data for user {user_id} not found") + + self.db.delete(user_data) + self.db.commit() + + except HTTPException: + raise + except Exception as e: + self.db.rollback() + self.logger.error(f"Error deleting user data: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/utilities/service_utils.py b/backend/app/utilities/service_utils.py index 46893b6e..d98aa014 100644 --- a/backend/app/utilities/service_utils.py +++ b/backend/app/utilities/service_utils.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import Session from ..services.implementations.auth_service import AuthService +from ..services.implementations.user_data_service import UserDataService from ..services.implementations.user_service import UserService from .db_utils import get_db @@ -12,6 +13,10 @@ def get_user_service(db: Session = Depends(get_db)): return UserService(db) +def get_user_data_service(db: Session = Depends(get_db)): + return UserDataService(db) + + def get_auth_service(db: Session = Depends(get_db)): logger = logging.getLogger(__name__) return AuthService(logger=logger, user_service=UserService(db)) diff --git a/backend/migrations/versions/d3e4f5g6h7i8_add_user_data_table_for_storing_user_matching_parameters.py b/backend/migrations/versions/d3e4f5g6h7i8_add_user_data_table_for_storing_user_matching_parameters.py new file mode 100644 index 00000000..30bd41f6 --- /dev/null +++ b/backend/migrations/versions/d3e4f5g6h7i8_add_user_data_table_for_storing_user_matching_parameters.py @@ -0,0 +1,62 @@ +"""Add user_data table for storing user matching parameters + +Revision ID: d3e4f5g6h7i8 +Revises: c9bc2b4d1036 +Create Date: 2024-11-25 12:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "d3e4f5g6h7i8" +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.create_table( + "user_data", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("date_of_birth", sa.Date(), nullable=True), + sa.Column("email", sa.String(length=120), nullable=True), + sa.Column("phone", sa.String(length=20), nullable=True), + sa.Column("postal_code", sa.String(length=10), nullable=True), + sa.Column("province", sa.String(length=50), nullable=True), + sa.Column("city", sa.String(length=100), nullable=True), + sa.Column("language", sa.String(length=50), nullable=True), + sa.Column("criminal_record_status", sa.Boolean(), nullable=True), + sa.Column("blood_cancer_status", sa.Boolean(), nullable=True), + sa.Column("caregiver_status", sa.Boolean(), nullable=True), + sa.Column("caregiver_type", sa.String(length=100), nullable=True), + sa.Column("diagnostic", sa.String(length=200), nullable=True), + sa.Column("date_of_diagnosis", sa.Date(), nullable=True), + sa.Column("gender_identity", sa.String(length=50), nullable=True), + sa.Column("pronouns", sa.String(length=50), nullable=True), + sa.Column("ethnicity", sa.String(length=100), nullable=True), + sa.Column("marital_status", sa.String(length=50), nullable=True), + sa.Column("children_status", sa.Boolean(), nullable=True), + sa.Column("treatment", sa.Text(), nullable=True), + sa.Column("experience", sa.Text(), nullable=True), + sa.Column("preferences", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("user_data") + # ### end Alembic commands ###