diff --git a/backend/app/models/Experience.py b/backend/app/models/Experience.py index 77ab3a55..5e89d9a7 100644 --- a/backend/app/models/Experience.py +++ b/backend/app/models/Experience.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String +from sqlalchemy import Column, Enum, Integer, String from sqlalchemy.orm import relationship from .Base import Base @@ -8,6 +8,7 @@ class Experience(Base): __tablename__ = "experiences" id = Column(Integer, primary_key=True) name = Column(String, unique=True, nullable=False) # 'PTSD', 'Relapse', etc. + scope = Column(Enum("patient", "caregiver", "both", "none", name="scope"), nullable=False) # Back reference for many-to-many relationship users = relationship("UserData", secondary="user_experiences", back_populates="experiences") diff --git a/backend/app/models/UserData.py b/backend/app/models/UserData.py index f4c26121..93c45f64 100644 --- a/backend/app/models/UserData.py +++ b/backend/app/models/UserData.py @@ -65,8 +65,6 @@ class UserData(Base): date_of_diagnosis = Column(Date, nullable=True) # "Other" text fields for custom entries - other_treatment = Column(Text, nullable=True) - other_experience = Column(Text, nullable=True) other_ethnic_group = Column(Text, nullable=True) gender_identity_custom = Column(Text, nullable=True) @@ -81,8 +79,6 @@ class UserData(Base): # Loved One Cancer Experience loved_one_diagnosis = Column(String(100), nullable=True) loved_one_date_of_diagnosis = Column(Date, nullable=True) - loved_one_other_treatment = Column(Text, nullable=True) - loved_one_other_experience = Column(Text, nullable=True) # Many-to-many relationships treatments = relationship("Treatment", secondary=user_treatments, back_populates="users") diff --git a/backend/app/routes/intake.py b/backend/app/routes/intake.py index dc7a4491..a62775f2 100644 --- a/backend/app/routes/intake.py +++ b/backend/app/routes/intake.py @@ -1,13 +1,14 @@ from datetime import datetime -from typing import List, Optional +from typing import List, Literal, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import or_ from sqlalchemy.orm import Session from app.middleware.auth import has_roles -from app.models import Form, FormSubmission, User +from app.models import Experience, Form, FormSubmission, Treatment, User from app.schemas.user import UserRole from app.services.implementations.intake_form_processor import IntakeFormProcessor from app.utilities.db_utils import get_db @@ -49,6 +50,24 @@ class FormSubmissionListResponse(BaseModel): total: int +class ExperienceResponse(BaseModel): + id: int + name: str + scope: Literal["patient", "caregiver", "both", "none"] + model_config = ConfigDict(from_attributes=True) + + +class TreatmentResponse(BaseModel): + id: int + name: str + model_config = ConfigDict(from_attributes=True) + + +class OptionsResponse(BaseModel): + experiences: List[ExperienceResponse] + treatments: List[TreatmentResponse] + + # ===== Custom Auth Dependencies ===== @@ -339,6 +358,31 @@ async def delete_form_submission( raise HTTPException(status_code=500, detail=str(e)) +@router.get( + "/options", + response_model=OptionsResponse, +) +async def get_intake_options( + request: Request, + target: str = Query(..., pattern="^(patient|caregiver|both)$"), + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.VOLUNTEER, UserRole.ADMIN]), +): + try: + # Query DB Experience Table + experiences_query = db.query(Experience) + if target != "both": + experiences_query = experiences_query.filter(or_(Experience.scope == target, Experience.scope == "both")) + experiences = experiences_query.order_by(Experience.id.asc()).all() + + treatments = db.query(Treatment).order_by(Treatment.id.asc()).all() + return OptionsResponse.model_validate({"experiences": experiences, "treatments": treatments}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + # ===== Additional Utility Endpoints ===== diff --git a/backend/app/seeds/experiences.py b/backend/app/seeds/experiences.py index fff1e318..110fe4c7 100644 --- a/backend/app/seeds/experiences.py +++ b/backend/app/seeds/experiences.py @@ -9,18 +9,19 @@ def seed_experiences(session: Session) -> None: """Seed the experiences table with cancer-related experiences.""" experiences_data = [ - {"id": 1, "name": "Brain Fog"}, - {"id": 2, "name": "Communication Challenges"}, - {"id": 3, "name": "Compassion Fatigue"}, - {"id": 4, "name": "Feeling Overwhelmed"}, - {"id": 5, "name": "Fatigue"}, - {"id": 6, "name": "Fertility Issues"}, - {"id": 7, "name": "Graft vs Host"}, - {"id": 8, "name": "Returning to work or school after/during treatment"}, - {"id": 9, "name": "Speaking to your family or friends about the diagnosis"}, - {"id": 10, "name": "Relapse"}, - {"id": 11, "name": "Anxiety / Depression"}, - {"id": 12, "name": "PTSD"}, + {"id": 1, "name": "Brain Fog", "scope": "both"}, + {"id": 2, "name": "Communication Challenges", "scope": "caregiver"}, + {"id": 3, "name": "Feeling Overwhelmed", "scope": "both"}, + {"id": 4, "name": "Fatigue", "scope": "both"}, + {"id": 5, "name": "Fertility Issues", "scope": "patient"}, + {"id": 6, "name": "Graft vs Host", "scope": "patient"}, + {"id": 7, "name": "Returning to work or school after/during treatment", "scope": "patient"}, + {"id": 8, "name": "Speaking to your family or friends about the diagnosis", "scope": "both"}, + {"id": 9, "name": "Relapse", "scope": "patient"}, + {"id": 10, "name": "Anxiety / Depression", "scope": "both"}, + {"id": 11, "name": "PTSD", "scope": "both"}, + {"id": 12, "name": "Caregiver Fatigue", "scope": "caregiver"}, + {"id": 13, "name": "Managing practical challenges", "scope": "caregiver"}, ] for experience_data in experiences_data: diff --git a/backend/app/services/implementations/intake_form_processor.py b/backend/app/services/implementations/intake_form_processor.py index 9f78759e..13fb57fe 100644 --- a/backend/app/services/implementations/intake_form_processor.py +++ b/backend/app/services/implementations/intake_form_processor.py @@ -175,8 +175,6 @@ def _process_demographics(self, user_data: UserData, demographics: Dict[str, Any def _process_cancer_experience(self, user_data: UserData, cancer_experience: Dict[str, Any]): """Process cancer experience information.""" user_data.diagnosis = self._trim_text(cancer_experience.get("diagnosis")) - user_data.other_treatment = self._trim_text(cancer_experience.get("other_treatment")) - user_data.other_experience = self._trim_text(cancer_experience.get("other_experience")) # Parse diagnosis date with strict validation if "date_of_diagnosis" in cancer_experience: @@ -213,13 +211,6 @@ def _process_treatments(self, user_data: UserData, cancer_exp: Dict[str, Any]): if treatment: user_data.treatments.append(treatment) - else: - # Create new treatment for custom entry - logger.info(f"Creating new treatment: {treatment_name}") - new_treatment = Treatment(name=treatment_name) - self.db.add(new_treatment) - self.db.flush() # Get the ID - user_data.treatments.append(new_treatment) def _process_experiences(self, user_data: UserData, cancer_exp: Dict[str, Any]): """ @@ -242,13 +233,6 @@ def _process_experiences(self, user_data: UserData, cancer_exp: Dict[str, Any]): if experience: user_data.experiences.append(experience) - else: - # Create new experience for custom entry - logger.info(f"Creating new experience: {experience_name}") - new_experience = Experience(name=experience_name) - self.db.add(new_experience) - self.db.flush() # Get the ID - user_data.experiences.append(new_experience) def _process_caregiver_experience(self, user_data: UserData, caregiver_exp: Dict[str, Any]): """ @@ -258,9 +242,6 @@ def _process_caregiver_experience(self, user_data: UserData, caregiver_exp: Dict if not caregiver_exp: return - # Handle "Other" caregiver experience text - user_data.other_experience = caregiver_exp.get("other_experience") - # Process caregiver experiences - map to same experiences table experience_names = caregiver_exp.get("experiences", []) if not experience_names: @@ -280,13 +261,6 @@ def _process_caregiver_experience(self, user_data: UserData, caregiver_exp: Dict # Only add if not already present if experience not in user_data.experiences: user_data.experiences.append(experience) - else: - # Create new experience for custom entry - logger.info(f"Creating new caregiver experience: {experience_name}") - new_experience = Experience(name=experience_name) - self.db.add(new_experience) - self.db.flush() # Get the ID - user_data.experiences.append(new_experience) def _process_loved_one_data(self, user_data: UserData, loved_one_data: Dict[str, Any]): """Process loved one data including demographics and cancer experience.""" @@ -327,10 +301,6 @@ def _process_loved_one_cancer_experience(self, user_data: UserData, cancer_exp: f"Invalid date format for loved one dateOfDiagnosis: {cancer_exp.get('date_of_diagnosis')}" ) - # Handle "Other" treatment and experience text for loved one with trimming - user_data.loved_one_other_treatment = self._trim_text(cancer_exp.get("other_treatment")) - user_data.loved_one_other_experience = self._trim_text(cancer_exp.get("other_experience")) - def _process_loved_one_treatments(self, user_data: UserData, cancer_exp: Dict[str, Any]): """Process loved one treatments - map frontend names to database records.""" treatment_names = cancer_exp.get("treatments", []) @@ -349,13 +319,6 @@ def _process_loved_one_treatments(self, user_data: UserData, cancer_exp: Dict[st if treatment: user_data.loved_one_treatments.append(treatment) - else: - # Create new treatment for custom entry - logger.info(f"Creating new loved one treatment: {treatment_name}") - new_treatment = Treatment(name=treatment_name) - self.db.add(new_treatment) - self.db.flush() # Get the ID - user_data.loved_one_treatments.append(new_treatment) def _process_loved_one_experiences(self, user_data: UserData, cancer_exp: Dict[str, Any]): """Process loved one experiences - map frontend names to database records.""" @@ -375,13 +338,6 @@ def _process_loved_one_experiences(self, user_data: UserData, cancer_exp: Dict[s if experience: user_data.loved_one_experiences.append(experience) - else: - # Create new experience for custom entry - logger.info(f"Creating new loved one experience: {experience_name}") - new_experience = Experience(name=experience_name) - self.db.add(new_experience) - self.db.flush() # Get the ID - user_data.loved_one_experiences.append(new_experience) def process_ranking_form(self, user_id: str, ranking_data: Dict[str, Any]): """ diff --git a/backend/docs/intake_api.md b/backend/docs/intake_api.md index 6d8d007b..b7fd40e2 100644 --- a/backend/docs/intake_api.md +++ b/backend/docs/intake_api.md @@ -44,8 +44,6 @@ Create a new form submission and process it into structured data. "dateOfDiagnosis": "DD/MM/YYYY (optional)", "treatments": ["array of treatment names (optional)"], "experiences": ["array of experience names (optional)"], - "otherTreatment": "string (optional)", - "otherExperience": "string (optional)" }, "lovedOne": { "demographics": { @@ -57,8 +55,6 @@ Create a new form submission and process it into structured data. "dateOfDiagnosis": "DD/MM/YYYY (optional)", "treatments": ["array of treatment names (optional)"], "experiences": ["array of experience names (optional)"], - "otherTreatment": "string (optional)", - "otherExperience": "string (optional)" } } } diff --git a/backend/migrations/versions/95467f4c5c80_add_scope_column_to_experience_table.py b/backend/migrations/versions/95467f4c5c80_add_scope_column_to_experience_table.py new file mode 100644 index 00000000..ad623a6d --- /dev/null +++ b/backend/migrations/versions/95467f4c5c80_add_scope_column_to_experience_table.py @@ -0,0 +1,43 @@ +"""add scope column to experience table + +Revision ID: 95467f4c5c80 +Revises: 905b6788b114 +Create Date: 2025-09-22 19:41:17.291555 + +""" + +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 = "95467f4c5c80" +down_revision: Union[str, None] = "905b6788b114" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + scope_enum = postgresql.ENUM("patient", "caregiver", "both", "none", name="scope") + scope_enum.create(op.get_bind(), checkfirst=True) + + op.add_column( + "experiences", + sa.Column( + "scope", + scope_enum, + nullable=False, + server_default=sa.text("'none'::scope"), # temporary default for NOT NULL + ), + ) + + op.alter_column("experiences", "scope", server_default=None) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("experiences", "scope") + # ### end Alembic commands ### diff --git a/backend/migrations/versions/a59aeb0bd691_remove_other_treatment_and_other_.py b/backend/migrations/versions/a59aeb0bd691_remove_other_treatment_and_other_.py new file mode 100644 index 00000000..7f361da7 --- /dev/null +++ b/backend/migrations/versions/a59aeb0bd691_remove_other_treatment_and_other_.py @@ -0,0 +1,36 @@ +"""remove other_treatment and other_experience columns from UserData table + +Revision ID: a59aeb0bd691 +Revises: 95467f4c5c80 +Create Date: 2025-09-25 20:22:55.535261 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a59aeb0bd691" +down_revision: Union[str, None] = "95467f4c5c80" +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.drop_column("user_data", "other_experience") + op.drop_column("user_data", "loved_one_other_experience") + op.drop_column("user_data", "other_treatment") + op.drop_column("user_data", "loved_one_other_treatment") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("user_data", sa.Column("loved_one_other_treatment", sa.TEXT(), autoincrement=False, nullable=True)) + op.add_column("user_data", sa.Column("other_treatment", sa.TEXT(), autoincrement=False, nullable=True)) + op.add_column("user_data", sa.Column("loved_one_other_experience", sa.TEXT(), autoincrement=False, nullable=True)) + op.add_column("user_data", sa.Column("other_experience", sa.TEXT(), autoincrement=False, nullable=True)) + # ### end Alembic commands ### diff --git a/backend/pdm.lock b/backend/pdm.lock index 2e403191..2bf1bfda 100644 --- a/backend/pdm.lock +++ b/backend/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:847e479aa02d5a9569f0b7dc0f6458685d7627180b11b79efc24e4df29fd3f9b" +content_hash = "sha256:0a1abed287908a98f22cfa7d57b89264608f5726444a7973504d19b099329afc" [[metadata.targets]] requires_python = "==3.12.*" @@ -1112,6 +1112,28 @@ files = [ {file = "psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11"}, ] +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +requires_python = ">=3.8" +summary = "psycopg2 - Python-PostgreSQL Database Adapter" +groups = ["default"] +files = [ + {file = "psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0"}, +] + [[package]] name = "pyasn1" version = "0.6.1" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8b198a8f..f5da457a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "psycopg2>=2.9.9", "boto3>=1.35.71", "pytest-asyncio>=0.25.3", + "psycopg2-binary>=2.9.10", ] requires-python = "==3.12.*" readme = "README.md" @@ -29,7 +30,7 @@ license = {text = "MIT"} distribution = false [tool.pdm.scripts] -dev = "fastapi dev app/server.py" +dev = "fastapi dev app/server.py --port 8080" precommit = "pre-commit run" precommit-install = "pre-commit install" dc-down = "docker-compose down -v" diff --git a/backend/test_intake.db b/backend/test_intake.db index ae147b15..d603d9b1 100644 Binary files a/backend/test_intake.db and b/backend/test_intake.db differ diff --git a/backend/tests/unit/test_intake_api.py b/backend/tests/unit/test_intake_api.py index baf6c1be..22063086 100644 --- a/backend/tests/unit/test_intake_api.py +++ b/backend/tests/unit/test_intake_api.py @@ -1,7 +1,17 @@ +from contextlib import contextmanager +from dataclasses import dataclass +from types import SimpleNamespace +from typing import List + +import firebase_admin.auth import pytest from fastapi.testclient import TestClient +from sqlalchemy.sql.elements import BinaryExpression, BooleanClauseList +from app.models import Experience, Treatment from app.server import app +from app.utilities.db_utils import get_db +from app.utilities.service_utils import get_auth_service # TODO: ADD MORE TESTS (testing for this is super mimimal at the moment) @@ -12,6 +22,18 @@ def client(): return TestClient(app) +@pytest.fixture(autouse=True) +def mock_firebase_auth(monkeypatch): + def _verify_id_token(token, check_revoked=True): + return {"uid": "test-user", "email": "test@example.com"} + + def _get_user(uid): + return SimpleNamespace(email_verified=True) + + monkeypatch.setattr(firebase_admin.auth, "verify_id_token", _verify_id_token) + monkeypatch.setattr(firebase_admin.auth, "get_user", _get_user) + + class TestIntakeAPI: """Basic API endpoint tests""" @@ -52,3 +74,195 @@ def test_intake_endpoint_exists(self, client): # Should not be 404 (endpoint exists) assert response.status_code != 404 + + """GET intake/options tests""" + + +@dataclass +class FakeExperienceRecord: + id: int + name: str + scope: str + + +@dataclass +class FakeTreatmentRecord: + id: int + name: str + + +def _extract_filter_values(condition) -> List[str]: + if isinstance(condition, BooleanClauseList): + values: List[str] = [] + for clause in condition.clauses: + values.extend(_extract_filter_values(clause)) + return values + if isinstance(condition, BinaryExpression): + right = getattr(condition, "right", None) + value = getattr(right, "value", None) if right is not None else None + return [value] if value is not None else [] + return [] + + +class FakeQuery: + def __init__(self, data: List): + self._data = list(data) + + def _clone(self, data: List): + return self.__class__(data) + + def order_by(self, clause): + element = getattr(clause, "element", None) + key_name = getattr(element, "key", None) if element is not None else None + if not key_name: + key_name = getattr(clause, "key", None) or "id" + sorted_data = sorted(self._data, key=lambda item: getattr(item, key_name)) + return self._clone(sorted_data) + + def all(self): + return list(self._data) + + +class FakeExperienceQuery(FakeQuery): + def filter(self, condition): + scopes = {value for value in _extract_filter_values(condition) if value is not None} + if not scopes: + return self._clone(self._data) + filtered = [item for item in self._data if item.scope in scopes] + return self._clone(filtered) + + +class FakeTreatmentQuery(FakeQuery): + pass + + +class FakeSession: + def __init__( + self, + experiences: List[FakeExperienceRecord], + treatments: List[FakeTreatmentRecord], + query_exception: Exception | None = None, + ): + self._experiences = experiences + self._treatments = treatments + self._query_exception = query_exception + + def query(self, model): + if self._query_exception: + raise self._query_exception + if model is Experience: + return FakeExperienceQuery(self._experiences) + if model is Treatment: + return FakeTreatmentQuery(self._treatments) + raise AssertionError(f"Unexpected model queried: {model}") + + def close(self): + """Mimic SQLAlchemy session close.""" + + +@contextmanager +def override_dependencies(session: FakeSession, authorized: bool = True): + def _override_db(): + yield session + + class DummyAuthService: + def is_authorized_by_role(self, token, roles): + return authorized + + app.dependency_overrides[get_db] = _override_db + app.dependency_overrides[get_auth_service] = lambda: DummyAuthService() + + try: + yield + finally: + app.dependency_overrides.pop(get_db, None) + app.dependency_overrides.pop(get_auth_service, None) + if hasattr(session, "close"): + session.close() + + +class TestGetIntakeOptions: + auth_header = {"Authorization": "Bearer test-token"} + + def test_patient_target_filters_and_sorts_experiences(self, client): + experiences = [ + FakeExperienceRecord(id=2, name="Caregiver Only", scope="caregiver"), + FakeExperienceRecord(id=3, name="Both Eligible", scope="both"), + FakeExperienceRecord(id=1, name="Patient Only", scope="patient"), + ] + treatments = [ + FakeTreatmentRecord(id=2, name="Radiation"), + FakeTreatmentRecord(id=1, name="Chemotherapy"), + ] + session = FakeSession(experiences, treatments) + + with override_dependencies(session): + response = client.get("/intake/options", params={"target": "patient"}, headers=self.auth_header) + + assert response.status_code == 200 + body = response.json() + experience_scopes = [exp["scope"] for exp in body["experiences"]] + experience_ids = [exp["id"] for exp in body["experiences"]] + assert experience_scopes == ["patient", "both"], "Only patient & both scopes should be returned" + assert experience_ids == [1, 3], "Experiences should be ordered by id ascending" + treatment_ids = [treatment["id"] for treatment in body["treatments"]] + assert treatment_ids == [1, 2], "Treatments should be ordered by id ascending" + + def test_caregiver_target_includes_both_scope(self, client): + experiences = [ + FakeExperienceRecord(id=1, name="Patient Experience", scope="patient"), + FakeExperienceRecord(id=2, name="Caregiver Experience", scope="caregiver"), + FakeExperienceRecord(id=3, name="Shared Experience", scope="both"), + ] + treatments = [FakeTreatmentRecord(id=1, name="Chemotherapy")] + session = FakeSession(experiences, treatments) + + with override_dependencies(session): + response = client.get("/intake/options", params={"target": "caregiver"}, headers=self.auth_header) + + assert response.status_code == 200 + body = response.json() + returned_scopes = {exp["scope"] for exp in body["experiences"]} + assert returned_scopes == {"caregiver", "both"} + assert all(exp["id"] in {2, 3} for exp in body["experiences"]) + + def test_both_target_returns_all_experiences(self, client): + experiences = [ + FakeExperienceRecord(id=1, name="Patient Experience", scope="patient"), + FakeExperienceRecord(id=2, name="Caregiver Experience", scope="caregiver"), + FakeExperienceRecord(id=3, name="Shared Experience", scope="both"), + ] + treatments = [FakeTreatmentRecord(id=1, name="Chemotherapy")] + session = FakeSession(experiences, treatments) + + with override_dependencies(session): + response = client.get("/intake/options", params={"target": "both"}, headers=self.auth_header) + + assert response.status_code == 200 + body = response.json() + assert [exp["id"] for exp in body["experiences"]] == [1, 2, 3] + assert [exp["scope"] for exp in body["experiences"]] == ["patient", "caregiver", "both"] + + def test_missing_authorization_header_returns_403(self, client): + response = client.get("/intake/options", params={"target": "patient"}) + assert response.status_code == 401 + + def test_invalid_target_returns_422(self, client): + session = FakeSession([], []) + + with override_dependencies(session): + response = client.get( + "/intake/options", + params={"target": "invalid"}, + headers=self.auth_header, + ) + assert response.status_code == 422 + + def test_database_error_returns_500(self, client): + session = FakeSession([], [], query_exception=RuntimeError("database down")) + + with override_dependencies(session): + response = client.get("/intake/options", params={"target": "patient"}, headers=self.auth_header) + + assert response.status_code == 500 + assert response.json()["detail"] == "database down" diff --git a/backend/tests/unit/test_intake_form_processor.py b/backend/tests/unit/test_intake_form_processor.py index 5603e8d9..eda98104 100644 --- a/backend/tests/unit/test_intake_form_processor.py +++ b/backend/tests/unit/test_intake_form_processor.py @@ -62,18 +62,40 @@ def db_session(): # Create test treatments (predefined) treatments = [ - Treatment(id=1, name="Chemotherapy"), - Treatment(id=2, name="Surgery"), - Treatment(id=3, name="Radiation Therapy"), + Treatment(id=1, name="Unknown"), + Treatment(id=2, name="Watch and Wait / Active Surveillance"), + Treatment(id=3, name="Chemotherapy"), + Treatment(id=4, name="Immunotherapy"), + Treatment(id=5, name="Oral Chemotherapy"), + Treatment(id=6, name="Radiation"), + Treatment(id=7, name="Maintenance Chemotherapy"), + Treatment(id=8, name="Palliative Care"), + Treatment(id=9, name="Transfusions"), + Treatment(id=10, name="Autologous Stem Cell Transplant"), + Treatment(id=11, name="Allogeneic Stem Cell Transplant"), + Treatment(id=12, name="Haplo Stem Cell Transplant"), + Treatment(id=13, name="CAR-T"), + Treatment(id=14, name="BTK Inhibitors"), ] for treatment in treatments: session.add(treatment) # Create test experiences (predefined) experiences = [ - Experience(id=1, name="Anxiety"), - Experience(id=2, name="Fatigue"), - Experience(id=3, name="Depression"), + Experience(id=1, name="Brain Fog", scope="both"), + Experience(id=2, name="Communication Challenges", scope="caregiver"), + Experience(id=3, name="Feeling Overwhelmed", scope="both"), + Experience(id=4, name="Fatigue", scope="both"), + Experience(id=5, name="Fertility Issues", scope="patient"), + Experience(id=6, name="Graft vs Host", scope="patient"), + Experience(id=7, name="Returning to work or school after/during treatment", scope="patient"), + Experience(id=8, name="Speaking to your family or friends about the diagnosis", scope="both"), + Experience(id=9, name="Relapse", scope="patient"), + Experience(id=10, name="Anxiety", scope="both"), + Experience(id=11, name="PTSD", scope="both"), + Experience(id=12, name="Caregiver Fatigue", scope="caregiver"), + Experience(id=13, name="Managing practical challenges", scope="caregiver"), + Experience(id=14, name="Depression", scope="both"), ] for experience in experiences: session.add(experience) @@ -142,10 +164,8 @@ def test_participant_with_cancer_only(db_session, test_user): "cancer_experience": { "diagnosis": "Leukemia", "date_of_diagnosis": "01/01/2023", - "treatments": ["Chemotherapy", "Surgery"], + "treatments": ["Chemotherapy", "Transfusions"], "experiences": ["Anxiety", "Fatigue"], - "other_treatment": "Some custom treatment details", - "other_experience": "Custom experience notes", }, } @@ -171,8 +191,6 @@ def test_participant_with_cancer_only(db_session, test_user): # Assert - Cancer Experience assert user_data.diagnosis == "Leukemia" assert user_data.date_of_diagnosis == date(2023, 1, 1) - assert user_data.other_treatment == "Some custom treatment details" - assert user_data.other_experience == "Custom experience notes" # Assert - Flow Control assert user_data.has_blood_cancer == "yes" @@ -181,7 +199,7 @@ def test_participant_with_cancer_only(db_session, test_user): # Assert - Treatments (many-to-many) treatment_names = [t.name for t in user_data.treatments] assert "Chemotherapy" in treatment_names - assert "Surgery" in treatment_names + assert "Transfusions" in treatment_names assert len(user_data.treatments) == 2 # Assert - Experiences (many-to-many) @@ -203,80 +221,6 @@ def test_participant_with_cancer_only(db_session, test_user): raise -def test_custom_treatments_and_experiences(db_session, test_user): - """Test that custom treatments and experiences are created in the database""" - try: - # Arrange - processor = IntakeFormProcessor(db_session) - form_data = { - "form_type": "participant", - "has_blood_cancer": "yes", - "caring_for_someone": "no", - "personal_info": { - "first_name": "Jane", - "last_name": "Smith", - "date_of_birth": "20/12/1990", - "phone_number": "555-987-6543", - "city": "Vancouver", - "province": "British Columbia", - "postal_code": "V6B 1A1", - }, - "demographics": { - "gender_identity": "Female", - "pronouns": ["she", "her"], - "ethnic_group": ["Asian"], - "marital_status": "Single", - "has_kids": "no", - }, - "cancer_experience": { - "diagnosis": "Lymphoma", - "date_of_diagnosis": "15/06/2022", - "treatments": ["Custom Treatment X", "Experimental Therapy Y"], # New treatments - "experiences": ["Custom Symptom A", "Unique Experience B"], # New experiences - "other_treatment": "Details about experimental treatment", - "other_experience": "Unique side effects experienced", - }, - } - - # Verify custom treatments/experiences don't exist yet - assert db_session.query(Treatment).filter(Treatment.name == "Custom Treatment X").first() is None - assert db_session.query(Experience).filter(Experience.name == "Custom Symptom A").first() is None - - # Act - user_data = processor.process_form_submission(str(test_user.id), form_data) - - # Assert - New treatments were created - custom_treatment_x = db_session.query(Treatment).filter(Treatment.name == "Custom Treatment X").first() - custom_treatment_y = db_session.query(Treatment).filter(Treatment.name == "Experimental Therapy Y").first() - assert custom_treatment_x is not None - assert custom_treatment_y is not None - - # Assert - New experiences were created - custom_symptom_a = db_session.query(Experience).filter(Experience.name == "Custom Symptom A").first() - unique_experience_b = db_session.query(Experience).filter(Experience.name == "Unique Experience B").first() - assert custom_symptom_a is not None - assert unique_experience_b is not None - - # Assert - User is linked to custom treatments and experiences - user_treatment_names = [t.name for t in user_data.treatments] - user_experience_names = [e.name for e in user_data.experiences] - - assert "Custom Treatment X" in user_treatment_names - assert "Experimental Therapy Y" in user_treatment_names - assert "Custom Symptom A" in user_experience_names - assert "Unique Experience B" in user_experience_names - - # Assert - Custom text fields are stored - assert user_data.other_treatment == "Details about experimental treatment" - assert user_data.other_experience == "Unique side effects experienced" - - db_session.commit() - - except Exception: - db_session.rollback() - raise - - def test_volunteer_caregiver_experience_processing(db_session, test_user): """Test processing volunteer caregiver experience (separate from cancer experience)""" try: @@ -303,18 +247,15 @@ def test_volunteer_caregiver_experience_processing(db_session, test_user): "has_kids": "yes", }, "caregiver_experience": { - "experiences": ["Financial Stress", "Relationship Changes"], - "other_experience": "Dealing with healthcare system complexity", + "experiences": ["Anxiety", "Depression"], }, "loved_one": { "demographics": {"gender_identity": "Male", "age": "45-54"}, "cancer_experience": { "diagnosis": "Brain Cancer", "date_of_diagnosis": "10/05/2020", - "treatments": ["Surgery", "Radiation Therapy"], - "experiences": ["Depression", "Cognitive Changes"], - "other_treatment": "Specialized brain surgery", - "other_experience": "Memory issues post-surgery", + "treatments": ["Transfusions", "Radiation"], + "experiences": ["Depression", "Fatigue"], }, }, } @@ -338,9 +279,8 @@ def test_volunteer_caregiver_experience_processing(db_session, test_user): # Assert - Caregiver Experience (mapped to user experiences) experience_names = [e.name for e in user_data.experiences] - assert "Financial Stress" in experience_names - assert "Relationship Changes" in experience_names - assert user_data.other_experience == "Dealing with healthcare system complexity" + assert "Anxiety" in experience_names + assert "Depression" in experience_names # Assert - No personal cancer experience assert user_data.diagnosis is None @@ -357,10 +297,10 @@ def test_volunteer_caregiver_experience_processing(db_session, test_user): loved_one_treatment_names = [t.name for t in user_data.loved_one_treatments] loved_one_experience_names = [e.name for e in user_data.loved_one_experiences] - assert "Surgery" in loved_one_treatment_names - assert "Radiation Therapy" in loved_one_treatment_names + assert "Transfusions" in loved_one_treatment_names + assert "Radiation" in loved_one_treatment_names assert "Depression" in loved_one_experience_names - assert "Cognitive Changes" in loved_one_experience_names + assert "Fatigue" in loved_one_experience_names db_session.commit() @@ -399,20 +339,16 @@ def test_form_submission_json_structure(db_session, test_user): "cancer_experience": { "diagnosis": "Ovarian Cancer", "date_of_diagnosis": "03/07/2022", - "treatments": ["Chemotherapy", "Custom Treatment Protocol"], - "experiences": ["Anxiety", "Custom Side Effect"], - "other_treatment": "Experimental immunotherapy trial", - "other_experience": "Severe neuropathy affecting daily activities", + "treatments": ["Chemotherapy", "CAR-T"], + "experiences": ["Anxiety", "Fertility Issues"], }, "loved_one": { "demographics": {"gender_identity": "Female", "age": "65+"}, "cancer_experience": { "diagnosis": "Lung Cancer", "date_of_diagnosis": "15/01/2021", - "treatments": ["Radiation Therapy", "Palliative Care"], - "experiences": ["Sleep Problems", "Loss of Appetite"], - "other_treatment": "Comfort care measures", - "other_experience": "End-of-life care planning", + "treatments": ["Radiation", "Palliative Care"], + "experiences": ["Brain Fog", "Feeling Overwhelmed"], }, }, } @@ -427,32 +363,16 @@ def test_form_submission_json_structure(db_session, test_user): assert "Other" in user_data.ethnic_group and "Asian" in user_data.ethnic_group assert user_data.other_ethnic_group == "Mixed heritage - Filipino and Indigenous" - # Assert - Custom Treatments Created - custom_treatment = db_session.query(Treatment).filter(Treatment.name == "Custom Treatment Protocol").first() - assert custom_treatment is not None - assert custom_treatment in user_data.treatments - - # Assert - Custom Experiences Created - custom_experience = db_session.query(Experience).filter(Experience.name == "Custom Side Effect").first() - assert custom_experience is not None - assert custom_experience in user_data.experiences - - # Assert - "Other" Text Fields - assert user_data.other_treatment == "Experimental immunotherapy trial" - assert user_data.other_experience == "Severe neuropathy affecting daily activities" - # Assert - Loved One Complex Data assert user_data.loved_one_gender_identity == "Female" assert user_data.loved_one_age == "65+" assert user_data.loved_one_diagnosis == "Lung Cancer" - assert user_data.loved_one_other_treatment == "Comfort care measures" - assert user_data.loved_one_other_experience == "End-of-life care planning" # Assert - Both User and Loved One Have Relationships - assert len(user_data.treatments) >= 2 # Chemo + Custom - assert len(user_data.experiences) >= 2 # Anxiety + Custom + assert len(user_data.treatments) >= 2 # Chemo + CAR-T + assert len(user_data.experiences) >= 2 # Anxiety + Fertility assert len(user_data.loved_one_treatments) >= 2 # Radiation + Palliative - assert len(user_data.loved_one_experiences) >= 2 # Sleep + Appetite + assert len(user_data.loved_one_experiences) >= 2 # Brain Fog + Feeling Overwhelmed db_session.commit() @@ -506,7 +426,6 @@ def test_empty_and_minimal_data_handling(db_session, test_user): # Assert - Optional sections remain None/empty assert user_data.diagnosis is None - assert user_data.other_treatment is None assert len(user_data.treatments) == 0 assert len(user_data.experiences) == 0 assert user_data.loved_one_gender_identity is None @@ -548,10 +467,8 @@ def test_participant_caregiver_without_cancer(db_session, test_user): "cancer_experience": { "diagnosis": "Prostate Cancer", "date_of_diagnosis": "20/03/2021", - "treatments": ["Surgery", "Hormone Therapy"], - "experiences": ["Anxiety", "Relationship Changes"], - "other_treatment": "Robotic surgery", - "other_experience": "Intimacy concerns", + "treatments": ["Transfusions", "Immunotherapy"], + "experiences": ["Anxiety", "Communication Challenges"], }, }, } @@ -582,16 +499,14 @@ def test_participant_caregiver_without_cancer(db_session, test_user): assert user_data.loved_one_age == "55-64" assert user_data.loved_one_diagnosis == "Prostate Cancer" assert user_data.loved_one_date_of_diagnosis == date(2021, 3, 20) - assert user_data.loved_one_other_treatment == "Robotic surgery" - assert user_data.loved_one_other_experience == "Intimacy concerns" # Assert - Loved One Relationships loved_one_treatment_names = [t.name for t in user_data.loved_one_treatments] loved_one_experience_names = [e.name for e in user_data.loved_one_experiences] - assert "Surgery" in loved_one_treatment_names - assert "Hormone Therapy" in loved_one_treatment_names + assert "Transfusions" in loved_one_treatment_names + assert "Immunotherapy" in loved_one_treatment_names assert "Anxiety" in loved_one_experience_names - assert "Relationship Changes" in loved_one_experience_names + assert "Communication Challenges" in loved_one_experience_names db_session.commit() @@ -629,20 +544,16 @@ def test_participant_cancer_patient_and_caregiver(db_session, test_user): "cancer_experience": { "diagnosis": "Lymphoma", "date_of_diagnosis": "15/08/2022", - "treatments": ["Chemotherapy", "Radiation Therapy"], + "treatments": ["Chemotherapy", "Radiation"], "experiences": ["Fatigue", "Depression"], - "other_treatment": "Targeted therapy", - "other_experience": "Cognitive fog", }, "loved_one": { "demographics": {"gender_identity": "Female", "age": "35-44"}, "cancer_experience": { "diagnosis": "Breast Cancer", "date_of_diagnosis": "10/01/2023", - "treatments": ["Surgery", "Chemotherapy"], - "experiences": ["Hair Loss", "Body Image Issues"], - "other_treatment": "Reconstruction surgery", - "other_experience": "Fertility concerns", + "treatments": ["Transfusions", "Chemotherapy"], + "experiences": ["Graft vs Host", "Feeling Overwhelmed"], }, }, } @@ -657,28 +568,26 @@ def test_participant_cancer_patient_and_caregiver(db_session, test_user): # Assert - Own Cancer Experience assert user_data.diagnosis == "Lymphoma" assert user_data.date_of_diagnosis == date(2022, 8, 15) - assert user_data.other_treatment == "Targeted therapy" - assert user_data.other_experience == "Cognitive fog" # Assert - Own Treatments/Experiences treatment_names = [t.name for t in user_data.treatments] experience_names = [e.name for e in user_data.experiences] assert "Chemotherapy" in treatment_names - assert "Radiation Therapy" in treatment_names + assert "Radiation" in treatment_names assert "Fatigue" in experience_names assert "Depression" in experience_names # Assert - Loved One Data assert user_data.loved_one_diagnosis == "Breast Cancer" assert user_data.loved_one_date_of_diagnosis == date(2023, 1, 10) - assert user_data.loved_one_other_treatment == "Reconstruction surgery" - assert user_data.loved_one_other_experience == "Fertility concerns" # Assert - Loved One Relationships loved_one_treatment_names = [t.name for t in user_data.loved_one_treatments] loved_one_experience_names = [e.name for e in user_data.loved_one_experiences] - assert "Surgery" in loved_one_treatment_names - assert "Hair Loss" in loved_one_experience_names + assert "Transfusions" in loved_one_treatment_names + assert "Chemotherapy" in loved_one_treatment_names + assert "Graft vs Host" in loved_one_experience_names + assert "Feeling Overwhelmed" in loved_one_experience_names # Assert - Custom demographics assert "Other" in user_data.ethnic_group @@ -742,8 +651,6 @@ def test_participant_no_cancer_experience(db_session, test_user): # Assert - No cancer-related data assert user_data.diagnosis is None assert user_data.date_of_diagnosis is None - assert user_data.other_treatment is None - assert user_data.other_experience is None assert len(user_data.treatments) == 0 assert len(user_data.experiences) == 0 @@ -788,10 +695,8 @@ def test_volunteer_cancer_patient_only(db_session, test_user): "cancer_experience": { "diagnosis": "Myeloma", "date_of_diagnosis": "12/05/2019", - "treatments": ["Chemotherapy", "Stem Cell Transplant"], - "experiences": ["Depression", "Survivorship Concerns"], - "other_treatment": "Maintenance therapy", - "other_experience": "Long-term survivor guilt", + "treatments": ["Chemotherapy", "Autologous Stem Cell Transplant"], + "experiences": ["Depression", "Anxiety"], }, } @@ -809,16 +714,14 @@ def test_volunteer_cancer_patient_only(db_session, test_user): # Assert - Cancer Experience assert user_data.diagnosis == "Myeloma" assert user_data.date_of_diagnosis == date(2019, 5, 12) - assert user_data.other_treatment == "Maintenance therapy" - assert user_data.other_experience == "Long-term survivor guilt" # Assert - Treatments/Experiences treatment_names = [t.name for t in user_data.treatments] experience_names = [e.name for e in user_data.experiences] assert "Chemotherapy" in treatment_names - assert "Stem Cell Transplant" in treatment_names + assert "Autologous Stem Cell Transplant" in treatment_names assert "Depression" in experience_names - assert "Survivorship Concerns" in experience_names + assert "Anxiety" in experience_names # Assert - No loved one data (not a caregiver) assert user_data.loved_one_gender_identity is None @@ -861,20 +764,16 @@ def test_volunteer_cancer_patient_and_caregiver(db_session, test_user): "cancer_experience": { "diagnosis": "Breast Cancer", "date_of_diagnosis": "08/11/2015", - "treatments": ["Surgery", "Chemotherapy", "Radiation Therapy"], - "experiences": ["Hair Loss", "Survivorship Concerns"], - "other_treatment": "Hormone blocking therapy", - "other_experience": "10-year survivor perspective", + "treatments": ["Transfusions", "Chemotherapy", "Radiation"], + "experiences": ["Graft vs Host", "Anxiety"], }, "loved_one": { "demographics": {"gender_identity": "Male", "age": "65+"}, "cancer_experience": { "diagnosis": "Pancreatic Cancer", "date_of_diagnosis": "25/09/2023", - "treatments": ["Surgery", "Palliative Care"], - "experiences": ["Loss of Appetite", "Fatigue"], - "other_treatment": "Whipple procedure", - "other_experience": "End-of-life discussions", + "treatments": ["Transfusions", "Palliative Care"], + "experiences": ["Brain Fog", "Fatigue"], }, }, } @@ -889,21 +788,17 @@ def test_volunteer_cancer_patient_and_caregiver(db_session, test_user): # Assert - Own Cancer Experience (10-year survivor) assert user_data.diagnosis == "Breast Cancer" assert user_data.date_of_diagnosis == date(2015, 11, 8) - assert user_data.other_treatment == "Hormone blocking therapy" - assert user_data.other_experience == "10-year survivor perspective" # Assert - Own Treatments (comprehensive) treatment_names = [t.name for t in user_data.treatments] - assert "Surgery" in treatment_names + assert "Transfusions" in treatment_names assert "Chemotherapy" in treatment_names - assert "Radiation Therapy" in treatment_names + assert "Radiation" in treatment_names assert len(user_data.treatments) == 3 # Assert - Loved One Data (current patient) assert user_data.loved_one_diagnosis == "Pancreatic Cancer" assert user_data.loved_one_date_of_diagnosis == date(2023, 9, 25) - assert user_data.loved_one_other_treatment == "Whipple procedure" - assert user_data.loved_one_other_experience == "End-of-life discussions" # Assert - Both user and loved one have data assert len(user_data.treatments) >= 3 @@ -968,8 +863,6 @@ def test_volunteer_no_cancer_experience(db_session, test_user): # Assert - No cancer-related data assert user_data.diagnosis is None assert user_data.date_of_diagnosis is None - assert user_data.other_treatment is None - assert user_data.other_experience is None assert len(user_data.treatments) == 0 assert len(user_data.experiences) == 0 @@ -1177,8 +1070,6 @@ def test_text_trimming_and_normalization(db_session, test_user): "date_of_diagnosis": "01/01/2020", "treatments": [" Surgery ", " Chemotherapy "], "experiences": [" Fatigue "], - "other_treatment": " Custom treatment ", - "other_experience": " Custom experience ", }, } @@ -1194,8 +1085,6 @@ def test_text_trimming_and_normalization(db_session, test_user): assert user_data.gender_identity == "Male" assert user_data.marital_status == "Single" assert user_data.diagnosis == "Leukemia" - assert user_data.other_treatment == "Custom treatment" - assert user_data.other_experience == "Custom experience" db_session.commit() @@ -1228,7 +1117,6 @@ def test_sql_injection_prevention(db_session, test_user): "date_of_diagnosis": "01/01/2020", "treatments": ["Surgery"], "experiences": ["Fatigue"], - "other_treatment": "'; INSERT INTO admin_users VALUES (1); --", }, } @@ -1238,7 +1126,6 @@ def test_sql_injection_prevention(db_session, test_user): # Verify the malicious strings are stored as literal text, not executed assert user_data.first_name == "'; DROP TABLE users; --" assert "DELETE FROM user_data" in user_data.last_name - assert "INSERT INTO admin_users VALUES" in user_data.other_treatment # Verify no actual SQL injection occurred by checking database integrity user_count = db_session.query(User).count() @@ -1279,8 +1166,6 @@ def test_unicode_and_special_characters(db_session, test_user): "date_of_diagnosis": "01/01/2020", "treatments": ["Chimiothérapie"], "experiences": ["Fatigue"], - "other_treatment": "Traitement spécialisé avec émojis 💊🏥", - "other_experience": "Expérience émotionnelle complexe 😔➡️😊", }, } @@ -1294,8 +1179,6 @@ def test_unicode_and_special_characters(db_session, test_user): assert "中国人" in user_data.other_ethnic_group assert "हिन्दी" in user_data.other_ethnic_group assert "🌍" in user_data.other_ethnic_group - assert "💊🏥" in user_data.other_treatment - assert "😔➡️😊" in user_data.other_experience db_session.commit() diff --git a/backend/tests/unit/test_ranking_service.py b/backend/tests/unit/test_ranking_service.py index 7f4983ef..56529055 100644 --- a/backend/tests/unit/test_ranking_service.py +++ b/backend/tests/unit/test_ranking_service.py @@ -180,7 +180,7 @@ async def test_options_caregiver_without_cancer(db_session: Session): self_experiences=["Caregiver Fatigue"], loved_one_diagnosis="CLL", loved_treatments=["Immunotherapy"], - loved_experiences=["Anxiety"], + loved_experiences=["Anxiety / Depression"], ) service = RankingService(db_session) diff --git a/frontend/src/components/intake/demographic-cancer-form.tsx b/frontend/src/components/intake/demographic-cancer-form.tsx index 2abb67f8..57480c80 100644 --- a/frontend/src/components/intake/demographic-cancer-form.tsx +++ b/frontend/src/components/intake/demographic-cancer-form.tsx @@ -5,37 +5,42 @@ import { FormField } from '@/components/ui/form-field'; import { InputGroup } from '@/components/ui/input-group'; import { CheckboxGroup } from '@/components/ui/checkbox-group'; import { COLORS, VALIDATION } from '@/constants/form'; +import baseAPIClient from '@/APIClients/baseAPIClient'; +import { IntakeExperience, IntakeTreatment } from '@/types/intakeTypes'; // Reusable Select component to replace inline styling -const StyledSelect: React.FC<{ +type StyledSelectProps = React.SelectHTMLAttributes & { children: React.ReactNode; - value?: string; - onChange?: (e: React.ChangeEvent) => void; error?: boolean; -}> = ({ children, value, onChange, error, ...props }) => ( - +}; + +const StyledSelect = React.forwardRef( + ({ children, error, style, ...props }, ref) => ( + + ), ); +StyledSelect.displayName = 'StyledSelect'; interface DemographicCancerFormData { genderIdentity: string; @@ -46,9 +51,7 @@ interface DemographicCancerFormData { diagnosis: string; dateOfDiagnosis: string; treatments: string[]; - otherTreatment: string; experiences: string[]; - otherExperience: string; } const DEFAULT_VALUES: DemographicCancerFormData = { @@ -60,43 +63,9 @@ const DEFAULT_VALUES: DemographicCancerFormData = { diagnosis: '', dateOfDiagnosis: '', treatments: [], - otherTreatment: '', experiences: [], - otherExperience: '', }; -const TREATMENT_OPTIONS = [ - 'Unknown', - 'Watch and Wait / Active Surveillance', - 'Chemotherapy', - 'Immunotherapy', - 'Oral Chemotherapy', - 'Radiation', - 'Maintenance Chemotherapy', - 'Palliative Care', - 'Transfusions', - 'Autologous Stem Cell Transplant', - 'Allogeneic Stem Cell Transplant', - 'Haplo Stem Cell Transplant', - 'CAR-T', - 'BTK Inhibitors', -]; - -const EXPERIENCE_OPTIONS = [ - 'Brain Fog', - 'Caregiver Fatigue', - 'Communication Challenges', - 'Feeling Overwhelmed', - 'Fatigue', - 'Fertility Issues', - 'Graft vs Host', - 'Returning to work or school after/during treatment', - 'Speaking to your family or friends about the diagnosis', - 'Relapse', - 'Anxiety / Depression', - 'PTSD', -]; - const DIAGNOSIS_OPTIONS = [ 'Acute Myeloid Leukaemia', 'Acute Lymphoblastic Leukaemia', @@ -114,6 +83,8 @@ const DIAGNOSIS_OPTIONS = [ interface DemographicCancerFormProps { formType?: 'participant' | 'volunteer'; onNext: (data: DemographicCancerFormData) => void; + hasBloodCancer?: 'yes' | 'no' | ''; + caringForSomeone?: 'yes' | 'no' | ''; } // Updated options to match Figma design - moved Self-describe to bottom @@ -296,28 +267,72 @@ const MultiSelectDropdown: React.FC<{ ); }; -export function DemographicCancerForm({ formType, onNext }: DemographicCancerFormProps) { - const { - control, - handleSubmit, - formState: { errors, isSubmitting }, - watch, - setValue, - } = useForm({ +export function DemographicCancerForm({ + formType, + onNext, + hasBloodCancer, + caringForSomeone, +}: DemographicCancerFormProps) { + const { control, handleSubmit, formState, watch } = useForm({ defaultValues: DEFAULT_VALUES, }); + const { errors, isSubmitting } = formState; // Local state for custom values const [genderIdentityCustom, setGenderIdentityCustom] = useState(''); const [pronounsCustom, setPronounsCustom] = useState(''); const [ethnicGroupCustom, setEthnicGroupCustom] = useState(''); + const [treatmentOptions, setTreatmentOptions] = useState([]); + const [experienceOptions, setExperienceOptions] = useState([]); + + useEffect(() => { + const run = async () => { + const hasBloodCancerBool = hasBloodCancer === 'yes'; + const caringForSomeoneBool = caringForSomeone === 'yes'; + + let target = ''; + if (hasBloodCancerBool && caringForSomeoneBool) { + target = 'both'; + } else if (hasBloodCancerBool) { + target = 'patient'; + } else if (caringForSomeoneBool) { + target = 'caregiver'; + } else { + // This form should only render if at least one of these answers is "yes". + console.error( + 'Invalid intake flow state: neither hasBloodCancer nor caringForSomeone is "yes". ' + + `Received hasBloodCancer="${hasBloodCancer}", caringForSomeone="${caringForSomeone}". ` + + 'This Demographic Cancer form expects at least one to be "yes".', + ); + alert( + 'We hit an unexpected state.\n\n' + + 'This step is only shown if you have blood cancer or are caring for someone with blood cancer. ' + + 'Please go back to the previous step and select "Yes" for one of those questions, or navigate to the basic demographics form.', + ); + return; + } + + const options = await getOptions(target); + console.log(options); + + setTreatmentOptions(options.treatments.map((treatment: IntakeTreatment) => treatment.name)); + + setExperienceOptions( + options.experiences.map((experience: IntakeExperience) => experience.name), + ); + }; + + run(); + }, [hasBloodCancer, caringForSomeone]); - const otherTreatment = watch('otherTreatment') || ''; - const otherExperience = watch('otherExperience') || ''; const genderIdentity = watch('genderIdentity') || ''; const pronouns = watch('pronouns') || []; const ethnicGroup = watch('ethnicGroup') || []; + const getOptions = async (target: string) => { + const options = await baseAPIClient.get(`/intake/options?target=${target}`); + return options.data; + }; const onSubmit = async (data: DemographicCancerFormData) => { try { // Merge custom values into the arrays @@ -700,23 +715,12 @@ export function DemographicCancerForm({ formType, onNext }: DemographicCancerFor { - if (value && value.includes('Other') && !otherTreatment.trim()) { - return 'Please specify the other treatment'; - } - return true; - }, - }} render={({ field }) => ( setValue('otherTreatment', value)} /> )} /> @@ -750,23 +754,12 @@ export function DemographicCancerForm({ formType, onNext }: DemographicCancerFor { - if (value && value.includes('Other') && !otherExperience.trim()) { - return 'Please specify the other experience'; - } - return true; - }, - }} render={({ field }) => ( setValue('otherExperience', value)} /> )} /> diff --git a/frontend/src/components/intake/loved-one-form.tsx b/frontend/src/components/intake/loved-one-form.tsx index cd49d1ff..a47559c9 100644 --- a/frontend/src/components/intake/loved-one-form.tsx +++ b/frontend/src/components/intake/loved-one-form.tsx @@ -1,41 +1,46 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Heading, Button, VStack, HStack, Text, Input } from '@chakra-ui/react'; import { Controller, useForm } from 'react-hook-form'; import { FormField } from '@/components/ui/form-field'; import { InputGroup } from '@/components/ui/input-group'; import { CheckboxGroup } from '@/components/ui/checkbox-group'; import { COLORS, VALIDATION } from '@/constants/form'; +import { IntakeExperience, IntakeTreatment } from '@/types/intakeTypes'; +import baseAPIClient from '@/APIClients/baseAPIClient'; // Reusable Select component to replace inline styling -const StyledSelect: React.FC<{ +type StyledSelectProps = React.SelectHTMLAttributes & { children: React.ReactNode; - value?: string; - onChange?: (e: React.ChangeEvent) => void; error?: boolean; -}> = ({ children, value, onChange, error, ...props }) => ( - +}; + +const StyledSelect = React.forwardRef( + ({ children, error, style, ...props }, ref) => ( + + ), ); +StyledSelect.displayName = 'StyledSelect'; interface LovedOneFormData { genderIdentity: string; @@ -43,9 +48,7 @@ interface LovedOneFormData { diagnosis: string; dateOfDiagnosis: string; treatments: string[]; - otherTreatment: string; experiences: string[]; - otherExperience: string; } const DEFAULT_VALUES: LovedOneFormData = { @@ -54,43 +57,9 @@ const DEFAULT_VALUES: LovedOneFormData = { diagnosis: '', dateOfDiagnosis: '', treatments: [], - otherTreatment: '', experiences: [], - otherExperience: '', }; -const TREATMENT_OPTIONS = [ - 'Unknown', - 'Watch and Wait / Active Surveillance', - 'Chemotherapy', - 'Immunotherapy', - 'Oral Chemotherapy', - 'Radiation', - 'Maintenance Chemotherapy', - 'Palliative Care', - 'Transfusions', - 'Autologous Stem Cell Transplant', - 'Allogeneic Stem Cell Transplant', - 'Haplo Stem Cell Transplant', - 'CAR-T', - 'BTK Inhibitors', -]; - -const EXPERIENCE_OPTIONS = [ - 'Brain Fog', - 'Caregiver Fatigue', - 'Communication Challenges', - 'Feeling Overwhelmed', - 'Fatigue', - 'Fertility Issues', - 'Graft vs Host', - 'Returning to work or school after/during treatment', - 'Speaking to your family or friends about the diagnosis', - 'Relapse', - 'Anxiety / Depression', - 'PTSD', -]; - const DIAGNOSIS_OPTIONS = [ 'Acute Myeloid Leukaemia', 'Acute Lymphoblastic Leukaemia', @@ -116,18 +85,38 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor handleSubmit, formState: { errors, isSubmitting }, watch, - setValue, } = useForm({ defaultValues: DEFAULT_VALUES, }); // Local state for custom values const [genderIdentityCustom, setGenderIdentityCustom] = useState(''); + const [treatmentOptions, setTreatmentOptions] = useState([]); + const [experienceOptions, setExperienceOptions] = useState([]); + + useEffect(() => { + const run = async () => { + const target = 'patient'; + + const options = await getOptions(target); + + setTreatmentOptions(options.treatments.map((treatment: IntakeTreatment) => treatment.name)); + + setExperienceOptions( + options.experiences.map((experience: IntakeExperience) => experience.name), + ); + }; + + run(); + }, []); - const otherTreatment = watch('otherTreatment') || ''; - const otherExperience = watch('otherExperience') || ''; const genderIdentity = watch('genderIdentity') || ''; + const getOptions = async (target: string) => { + const options = await baseAPIClient.get(`/intake/options?target=${target}`); + return options.data; + }; + const onFormSubmit = async (data: LovedOneFormData) => { try { // Merge custom values into the data @@ -383,23 +372,12 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor { - if (value && value.includes('Other') && !otherTreatment.trim()) { - return 'Please specify the other treatment'; - } - return true; - }, - }} render={({ field }) => ( setValue('otherTreatment', value)} /> )} /> @@ -433,23 +411,12 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor { - if (value && value.includes('Other') && !otherExperience.trim()) { - return 'Please specify the other experience'; - } - return true; - }, - }} render={({ field }) => ( setValue('otherExperience', value)} /> )} /> diff --git a/frontend/src/components/ui/checkbox-group.tsx b/frontend/src/components/ui/checkbox-group.tsx index 8766d465..b75e8b43 100644 --- a/frontend/src/components/ui/checkbox-group.tsx +++ b/frontend/src/components/ui/checkbox-group.tsx @@ -8,9 +8,6 @@ export interface CheckboxGroupProps { selectedValues: string[]; onValueChange: (values: string[]) => void; maxSelections: number; - showOther?: boolean; - otherValue?: string; - onOtherChange?: (value: string) => void; } export const CheckboxGroup: React.FC = ({ @@ -18,9 +15,6 @@ export const CheckboxGroup: React.FC = ({ selectedValues, onValueChange, maxSelections, - showOther = false, - otherValue = '', - onOtherChange, }) => { const handleCheckboxChange = (option: string) => { if (selectedValues.includes(option)) { @@ -45,36 +39,6 @@ export const CheckboxGroup: React.FC = ({ ))} - - {showOther && ( - - handleCheckboxChange('other')} - disabled={!selectedValues.includes('other') && selectedValues.length >= maxSelections} - colorScheme="teal" - > - - Other: - - - onOtherChange?.(e.target.value)} - placeholder="" - fontFamily="system-ui, -apple-system, sans-serif" - fontSize="14px" - color={COLORS.veniceBlue} - borderColor="#d1d5db" - borderRadius="6px" - h="32px" - maxW="200px" - _placeholder={{ color: '#9ca3af' }} - _focus={{ borderColor: COLORS.teal, boxShadow: `0 0 0 3px ${COLORS.teal}20` }} - disabled={!selectedValues.includes('other')} - /> - - )} ); }; diff --git a/frontend/src/constants/form.ts b/frontend/src/constants/form.ts index ebfdbdb3..5c1ca94b 100644 --- a/frontend/src/constants/form.ts +++ b/frontend/src/constants/form.ts @@ -66,14 +66,11 @@ export interface IntakeFormData { dateOfDiagnosis: string; treatments: string[]; experiences: string[]; - otherTreatment?: string; - otherExperience?: string; }; // User's Caregiver Experience (if applicable) caregiverExperience?: { experiences: string[]; - otherExperience?: string; }; // Loved One's Information (if applicable) @@ -88,8 +85,6 @@ export interface IntakeFormData { dateOfDiagnosis: string; treatments: string[]; experiences: string[]; - otherTreatment?: string; - otherExperience?: string; }; }; } @@ -123,13 +118,10 @@ export interface CancerExperienceData { dateOfDiagnosis: string; treatments: string[]; experiences: string[]; - otherTreatment?: string; - otherExperience?: string; } export interface CaregiverExperienceData { experiences: string[]; - otherExperience?: string; } export interface LovedOneData { diff --git a/frontend/src/pages/participant/intake.tsx b/frontend/src/pages/participant/intake.tsx index 78f7a897..8c87b23f 100644 --- a/frontend/src/pages/participant/intake.tsx +++ b/frontend/src/pages/participant/intake.tsx @@ -28,9 +28,7 @@ interface DemographicCancerFormData { diagnosis: string; dateOfDiagnosis: string; treatments: string[]; - otherTreatment: string; experiences: string[]; - otherExperience: string; } interface LovedOneFormData { @@ -40,9 +38,7 @@ interface LovedOneFormData { diagnosis: string; dateOfDiagnosis: string; treatments: string[]; - otherTreatment: string; experiences: string[]; - otherExperience: string; } interface BasicDemographicsFormData { @@ -133,15 +129,12 @@ export default function ParticipantIntakePage() { dateOfDiagnosis: data.dateOfDiagnosis, treatments: data.treatments, experiences: data.experiences, - otherTreatment: data.otherTreatment, - otherExperience: data.otherExperience, }, }), ...(prev.hasBloodCancer === 'no' && prev.caringForSomeone === 'yes' && { caregiverExperience: { experiences: data.experiences, - otherExperience: data.otherExperience, }, }), } as IntakeFormData; @@ -166,8 +159,6 @@ export default function ParticipantIntakePage() { dateOfDiagnosis: data.dateOfDiagnosis, treatments: data.treatments, experiences: data.experiences, - otherTreatment: data.otherTreatment, - otherExperience: data.otherExperience, }, }, }; @@ -219,11 +210,21 @@ export default function ParticipantIntakePage() { )} {currentStepType === 'demographics-cancer' && ( - + )} {currentStepType === 'demographics-caregiver' && ( - + )} {currentStepType === 'loved-one' && ( diff --git a/frontend/src/pages/volunteer/intake.tsx b/frontend/src/pages/volunteer/intake.tsx index d5750624..1d8ddbf0 100644 --- a/frontend/src/pages/volunteer/intake.tsx +++ b/frontend/src/pages/volunteer/intake.tsx @@ -28,9 +28,7 @@ interface DemographicCancerFormData { diagnosis: string; dateOfDiagnosis: string; treatments: string[]; - otherTreatment: string; experiences: string[]; - otherExperience: string; } interface LovedOneFormData { @@ -40,9 +38,7 @@ interface LovedOneFormData { diagnosis: string; dateOfDiagnosis: string; treatments: string[]; - otherTreatment: string; experiences: string[]; - otherExperience: string; } interface BasicDemographicsFormData { @@ -142,15 +138,12 @@ export default function VolunteerIntakePage() { dateOfDiagnosis: data.dateOfDiagnosis, treatments: data.treatments, experiences: data.experiences, - otherTreatment: data.otherTreatment, - otherExperience: data.otherExperience, }, }), ...(prev.hasBloodCancer === 'no' && prev.caringForSomeone === 'yes' && { caregiverExperience: { experiences: data.experiences, - otherExperience: data.otherExperience, }, }), } as IntakeFormData; @@ -175,8 +168,6 @@ export default function VolunteerIntakePage() { dateOfDiagnosis: data.dateOfDiagnosis, treatments: data.treatments, experiences: data.experiences, - otherTreatment: data.otherTreatment, - otherExperience: data.otherExperience, }, }, }; @@ -228,11 +219,21 @@ export default function VolunteerIntakePage() { )} {currentStepType === 'demographics-cancer' && ( - + )} {currentStepType === 'demographics-caregiver' && ( - + )} {currentStepType === 'loved-one' && ( diff --git a/frontend/src/types/intakeTypes.ts b/frontend/src/types/intakeTypes.ts new file mode 100644 index 00000000..f1f45f8e --- /dev/null +++ b/frontend/src/types/intakeTypes.ts @@ -0,0 +1,15 @@ +export interface IntakeExperience { + id: number; + name: string; + scope: string; +} + +export interface IntakeTreatment { + id: number; + name: string; +} + +export interface IntakeOptionsResponse { + experiences: IntakeExperience[]; + treatments: IntakeTreatment[]; +}