From 01102654d0091efe207b7d731323acc553f04316 Mon Sep 17 00:00:00 2001 From: Evan Wu Date: Thu, 18 Sep 2025 01:27:35 +0800 Subject: [PATCH 1/4] finished matching --- backend/app/interfaces/matching_service.py | 23 + backend/app/middleware/auth_middleware.py | 17 +- backend/app/routes/matching.py | 34 ++ backend/app/schemas/matching.py | 24 + backend/app/schemas/user.py | 2 + backend/app/seeds/match_status.py | 30 ++ backend/app/seeds/ranking_preferences.py | 175 +++++++ backend/app/seeds/runner.py | 4 + backend/app/seeds/users.py | 330 +++++++++++++ backend/app/server.py | 4 +- .../implementations/matching_service.py | 453 ++++++++++++++++++ backend/app/utilities/db_utils.py | 6 +- backend/app/utilities/form_constants.py | 42 ++ 13 files changed, 1132 insertions(+), 12 deletions(-) create mode 100644 backend/app/interfaces/matching_service.py create mode 100644 backend/app/routes/matching.py create mode 100644 backend/app/schemas/matching.py create mode 100644 backend/app/seeds/match_status.py create mode 100644 backend/app/seeds/ranking_preferences.py create mode 100644 backend/app/seeds/users.py create mode 100644 backend/app/services/implementations/matching_service.py create mode 100644 backend/app/utilities/form_constants.py diff --git a/backend/app/interfaces/matching_service.py b/backend/app/interfaces/matching_service.py new file mode 100644 index 00000000..38ad5b30 --- /dev/null +++ b/backend/app/interfaces/matching_service.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, List +from uuid import UUID + + +class IMatchingService(ABC): + """ + Interface for the Matching Service, defining methods to find + potential matches between users. + """ + + @abstractmethod + async def get_matches(self, user_id: UUID) -> List[Dict[str, Any]]: + """ + Find potential matches based on the given user ID. + + :param user_id: ID of the user to find matches for + :type user_id: UUID + :return: List of dictionaries with 'user' and 'score' keys + :rtype: List[Dict[str, Any]] + :raises Exception: If matching process fails + """ + pass diff --git a/backend/app/middleware/auth_middleware.py b/backend/app/middleware/auth_middleware.py index 8f8e52c4..2f103f6a 100644 --- a/backend/app/middleware/auth_middleware.py +++ b/backend/app/middleware/auth_middleware.py @@ -17,14 +17,15 @@ def __init__(self, app: ASGIApp, public_paths: List[str] = None): self.logger = logging.getLogger(LOGGER_NAME("auth_middleware")) def is_public_path(self, path: str) -> bool: - for public_path in self.public_paths: - # Handle parameterized routes by checking if path starts with the pattern - if public_path.endswith("{email}") and path.startswith(public_path.replace("{email}", "")): - return True - # Exact match for non-parameterized routes - if path == public_path: - return True - return False + return True + # for public_path in self.public_paths: + # # Handle parameterized routes by checking if path starts with the pattern + # if public_path.endswith("{email}") and path.startswith(public_path.replace("{email}", "")): + # return True + # # Exact match for non-parameterized routes + # if path == public_path: + # return True + # return False async def dispatch(self, request: Request, call_next): # Allow preflight CORS requests to pass through without auth diff --git a/backend/app/routes/matching.py b/backend/app/routes/matching.py new file mode 100644 index 00000000..252baaf0 --- /dev/null +++ b/backend/app/routes/matching.py @@ -0,0 +1,34 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.schemas.matching import RelevantUsersResponse +from app.services.implementations.matching_service import MatchingService +from app.utilities.db_utils import get_db + +router = APIRouter( + prefix="/matching", + tags=["matching"], +) + +def get_matching_service(db: Session = Depends(get_db)): + return MatchingService(db) + +@router.get("/{user_id}", response_model=RelevantUsersResponse) +async def get_matches( + user_id: UUID, + matching_service: MatchingService = Depends(get_matching_service) +): + """ + Get potential user matches based on the user's profile. + """ + try: + matched_data = await matching_service.get_matches(user_id) + return RelevantUsersResponse(matches=matched_data) + except ValueError as ve: + raise HTTPException(status_code=404, detail=str(ve)) + 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/matching.py b/backend/app/schemas/matching.py new file mode 100644 index 00000000..96eae8c3 --- /dev/null +++ b/backend/app/schemas/matching.py @@ -0,0 +1,24 @@ +""" +Pydantic schemas for matching-related data validation and serialization. +""" + +from typing import List + +from pydantic import BaseModel + +from .user import UserBase + + +class MatchedUser(BaseModel): + """ + Schema for a matched user with their compatibility score. + """ + user: UserBase + score: float + + +class RelevantUsersResponse(BaseModel): + """ + Response schema for matching endpoint containing a list of relevant users with scores. + """ + matches: List[MatchedUser] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 2d7d8eca..ab1d4658 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -45,6 +45,8 @@ class UserBase(BaseModel): email: EmailStr role: UserRole + model_config = ConfigDict(from_attributes=True) + class UserCreateRequest(UserBase): """ diff --git a/backend/app/seeds/match_status.py b/backend/app/seeds/match_status.py new file mode 100644 index 00000000..aee45eb1 --- /dev/null +++ b/backend/app/seeds/match_status.py @@ -0,0 +1,30 @@ +"""Seed match status data.""" + +from sqlalchemy.orm import Session + +from app.models.MatchStatus import MatchStatus + + +def seed_match_status(session: Session) -> None: + """Seed the match_status table with default statuses.""" + + match_status_data = [ + {"id": 1, "name": "pending"}, + {"id": 2, "name": "confirmed"}, + {"id": 3, "name": "cancelled"}, + {"id": 4, "name": "completed"}, + {"id": 5, "name": "no_show"}, + {"id": 6, "name": "rescheduled"}, + ] + + for status_data in match_status_data: + # Check if status already exists + existing_status = session.query(MatchStatus).filter_by(id=status_data["id"]).first() + if not existing_status: + status = MatchStatus(**status_data) + session.add(status) + print(f"Added match status: {status_data['name']}") + else: + print(f"Match status already exists: {status_data['name']}") + + session.commit() diff --git a/backend/app/seeds/ranking_preferences.py b/backend/app/seeds/ranking_preferences.py new file mode 100644 index 00000000..76755957 --- /dev/null +++ b/backend/app/seeds/ranking_preferences.py @@ -0,0 +1,175 @@ +"""Seed ranking preferences data.""" + +from sqlalchemy.orm import Session + +from app.models.RankingPreference import RankingPreference +from app.models.User import User +from app.utilities.form_constants import QualityId, TreatmentId + + +def seed_ranking_preferences(session: Session) -> None: + """Seed the ranking_preferences table with sample ranking data for testing all matching cases.""" + + # Find users by email instead of hardcoding UUIDs + + # Test Case 1: Patient wants cancer patient volunteer + sarah_user = session.query(User).filter_by(email="sarah.johnson@example.com").first() + sarah_id = sarah_user.id if sarah_user else None + + # Test Case 2: Caregiver wants ONLY cancer patient volunteers + lisa_user = session.query(User).filter_by(email="lisa.rodriguez@example.com").first() + lisa_id = lisa_user.id if lisa_user else None + + # Test Case 3: Caregiver wants ONLY caregiver volunteers + karen_user = session.query(User).filter_by(email="karen.davis@example.com").first() + karen_id = karen_user.id if karen_user else None + + ranking_data = [ + # CASE 1: Sarah (patient) wants patient volunteers - 5 preferences + { + "user_id": sarah_id, + "target_role": "patient", + "kind": "quality", + "quality_id": QualityId.SAME_DIAGNOSIS, + "scope": "self", + "rank": 1 + }, + { + "user_id": sarah_id, + "target_role": "patient", + "kind": "quality", + "quality_id": QualityId.SAME_GENDER_IDENTITY, + "scope": "self", + "rank": 2 + }, + { + "user_id": sarah_id, + "target_role": "patient", + "kind": "quality", + "quality_id": QualityId.SAME_AGE, + "scope": "self", + "rank": 3 + }, + { + "user_id": sarah_id, + "target_role": "patient", + "kind": "treatment", + "treatment_id": TreatmentId.CHEMOTHERAPY, + "scope": "self", + "rank": 4 + }, + { + "user_id": sarah_id, + "target_role": "patient", + "kind": "treatment", + "treatment_id": TreatmentId.RADIATION, + "scope": "self", + "rank": 5 + }, + + # CASE 2: Lisa (caregiver) wants ONLY patient volunteers - 5 preferences + { + "user_id": lisa_id, + "target_role": "patient", + "kind": "quality", + "quality_id": QualityId.SAME_DIAGNOSIS, + "scope": "loved_one", + "rank": 1 + }, + { + "user_id": lisa_id, + "target_role": "patient", + "kind": "quality", + "quality_id": QualityId.SAME_GENDER_IDENTITY, + "scope": "loved_one", + "rank": 2 + }, + { + "user_id": lisa_id, + "target_role": "patient", + "kind": "quality", + "quality_id": QualityId.SAME_AGE, + "scope": "loved_one", + "rank": 3 + }, + { + "user_id": lisa_id, + "target_role": "patient", + "kind": "treatment", + "treatment_id": TreatmentId.CHEMOTHERAPY, + "scope": "loved_one", + "rank": 4 + }, + { + "user_id": lisa_id, + "target_role": "patient", + "kind": "treatment", + "treatment_id": TreatmentId.RADIATION, + "scope": "loved_one", + "rank": 5 + }, + + # CASE 3: Karen (caregiver) wants ONLY caregiver volunteers - 5 preferences + { + "user_id": karen_id, + "target_role": "caregiver", + "kind": "quality", + "quality_id": QualityId.SAME_MARITAL_STATUS, + "scope": "self", + "rank": 1 + }, + { + "user_id": karen_id, + "target_role": "caregiver", + "kind": "quality", + "quality_id": QualityId.SAME_PARENTAL_STATUS, + "scope": "self", + "rank": 2 + }, + { + "user_id": karen_id, + "target_role": "caregiver", + "kind": "quality", + "quality_id": QualityId.SAME_GENDER_IDENTITY, + "scope": "self", + "rank": 3 + }, + { + "user_id": karen_id, + "target_role": "caregiver", + "kind": "quality", + "quality_id": QualityId.SAME_AGE, + "scope": "self", + "rank": 4 + }, + { + "user_id": karen_id, + "target_role": "caregiver", + "kind": "quality", + "quality_id": QualityId.SAME_DIAGNOSIS, + "scope": "loved_one", # Match caregiver's loved one diagnosis with volunteer's loved one + "rank": 5 + } + ] + + for pref_data in ranking_data: + # Skip Karen's preferences if she doesn't exist yet + if pref_data["user_id"] is None: + continue + + # Check if preference already exists + existing_pref = session.query(RankingPreference).filter_by( + user_id=pref_data["user_id"], + target_role=pref_data["target_role"], + kind=pref_data["kind"], + rank=pref_data["rank"] + ).first() + + if not existing_pref: + preference = RankingPreference(**pref_data) + session.add(preference) + print(f"Added ranking preference: {pref_data['kind']} rank {pref_data['rank']} for user {pref_data['user_id']}") + else: + print(f"Ranking preference already exists: {pref_data['kind']} rank {pref_data['rank']} for user {pref_data['user_id']}") + + session.commit() diff --git a/backend/app/seeds/runner.py b/backend/app/seeds/runner.py index 11de1d22..a43c8470 100644 --- a/backend/app/seeds/runner.py +++ b/backend/app/seeds/runner.py @@ -15,8 +15,10 @@ from .experiences import seed_experiences from .forms import seed_forms from .qualities import seed_qualities +from .ranking_preferences import seed_ranking_preferences from .roles import seed_roles from .treatments import seed_treatments +from .users import seed_users # Load environment variables load_dotenv() @@ -55,6 +57,8 @@ def seed_database(verbose: bool = True) -> None: ("Experiences", seed_experiences), ("Qualities", seed_qualities), ("Forms", seed_forms), + ("Users", seed_users), + ("Ranking Preferences", seed_ranking_preferences), ] for name, seed_func in seed_functions: diff --git a/backend/app/seeds/users.py b/backend/app/seeds/users.py new file mode 100644 index 00000000..353c5854 --- /dev/null +++ b/backend/app/seeds/users.py @@ -0,0 +1,330 @@ +"""Seed users data for testing matching functionality.""" + +import uuid +from datetime import date + +from sqlalchemy.orm import Session + +from app.models.Experience import Experience +from app.models.Treatment import Treatment +from app.models.User import User +from app.models.UserData import UserData +from app.utilities.form_constants import ExperienceId, TreatmentId + + +def seed_users(session: Session) -> None: + """Seed users (patients/volunteers) with their basic data.""" + + # Sample users data + users_data = [ + # Participants (patients/caregivers) + { + "role": "participant", + "user_data": { + "first_name": "Sarah", + "last_name": "Johnson", + "email": "sarah.johnson@example.com", + "auth_id": "auth_sarah_001", + "date_of_birth": date(1985, 3, 15), + "phone": "555-0101", + "city": "Toronto", + "province": "Ontario", + "postal_code": "M5V 3A8", + "gender_identity": "Woman", + "pronouns": ["she", "her"], + "ethnic_group": ["White/Caucasian"], + "marital_status": "Married", + "has_kids": "Yes", + "diagnosis": "Acute Lymphoblastic Leukemia", + "date_of_diagnosis": date(2023, 8, 10), + "has_blood_cancer": "Yes", + "caring_for_someone": "No", + }, + "treatments": [3, 6], # Chemotherapy, Radiation + "experiences": [1, 4, 5], # Brain Fog, Feeling Overwhelmed, Fatigue + }, + { + "role": "participant", + "user_data": { + "first_name": "Michael", + "last_name": "Chen", + "email": "michael.chen@example.com", + "auth_id": "auth_michael_002", + "date_of_birth": date(1978, 11, 22), + "phone": "555-0102", + "city": "Vancouver", + "province": "British Columbia", + "postal_code": "V6B 1A1", + "gender_identity": "Man", + "pronouns": ["he", "him"], + "ethnic_group": ["Asian"], + "marital_status": "Single", + "has_kids": "No", + "diagnosis": "Chronic Lymphocytic Leukemia", + "date_of_diagnosis": date(2024, 1, 5), + "has_blood_cancer": "Yes", + "caring_for_someone": "No", + }, + "treatments": [2, 14], # Watch and Wait, BTK Inhibitors + "experiences": [11, 12], # Anxiety/Depression, PTSD + }, + { + "role": "participant", + "user_data": { + "first_name": "Lisa", + "last_name": "Rodriguez", + "email": "lisa.rodriguez@example.com", + "auth_id": "auth_lisa_003", + "date_of_birth": date(1972, 7, 8), + "phone": "555-0103", + "city": "Montreal", + "province": "Quebec", + "postal_code": "H3A 0G4", + "gender_identity": "Woman", + "pronouns": ["she", "her"], + "ethnic_group": ["Hispanic/Latino"], + "marital_status": "Married", + "has_kids": "Yes", + "has_blood_cancer": "No", + "caring_for_someone": "Yes", + "loved_one_gender_identity": "Man", + "loved_one_age": "55", + "loved_one_diagnosis": "Multiple Myeloma", + "loved_one_date_of_diagnosis": date(2023, 12, 15), + }, + "treatments": [], # Caregiver, no personal treatments + "experiences": [ExperienceId.COMPASSION_FATIGUE, ExperienceId.FEELING_OVERWHELMED, ExperienceId.SPEAKING_TO_FAMILY], + }, + + # Volunteers + { + "role": "volunteer", + "user_data": { + "first_name": "David", + "last_name": "Thompson", + "email": "david.thompson@example.com", + "auth_id": "auth_david_004", + "date_of_birth": date(1980, 5, 12), + "phone": "555-0201", + "city": "Toronto", + "province": "Ontario", + "postal_code": "M4W 1A8", + "gender_identity": "Man", + "pronouns": ["he", "him"], + "ethnic_group": ["White/Caucasian"], + "marital_status": "Married", + "has_kids": "Yes", + "diagnosis": "Acute Lymphoblastic Leukemia", + "date_of_diagnosis": date(2018, 4, 20), # Survivor + "has_blood_cancer": "Yes", + "caring_for_someone": "No", + }, + "treatments": [TreatmentId.CHEMOTHERAPY, TreatmentId.RADIATION, TreatmentId.AUTOLOGOUS_STEM_CELL_TRANSPLANT], + "experiences": [ExperienceId.BRAIN_FOG, ExperienceId.FEELING_OVERWHELMED, ExperienceId.FATIGUE, ExperienceId.RETURNING_TO_WORK], + }, + { + "role": "volunteer", + "user_data": { + "first_name": "Jennifer", + "last_name": "Kim", + "email": "jennifer.kim@example.com", + "auth_id": "auth_jennifer_005", + "date_of_birth": date(1986, 9, 30), # Similar age to Sarah (1985) + "phone": "555-0202", + "city": "Toronto", # Same city as Sarah + "province": "Ontario", # Same province as Sarah + "postal_code": "M5V 2H1", + "gender_identity": "Woman", # Same as Sarah + "pronouns": ["she", "her"], + "ethnic_group": ["Asian"], + "marital_status": "Married", # Same as Sarah + "has_kids": "Yes", # Same as Sarah + "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! + "date_of_diagnosis": date(2020, 8, 15), # Survivor + "has_blood_cancer": "Yes", + "caring_for_someone": "No", + }, + "treatments": [3, 6], # Chemotherapy, Radiation (matching Sarah's preferences) + "experiences": [1, 4, 5], # Brain Fog, Feeling Overwhelmed, Fatigue (same as Sarah!) + }, + { + "role": "volunteer", + "user_data": { + "first_name": "Robert", + "last_name": "Williams", + "email": "robert.williams@example.com", + "auth_id": "auth_robert_006", + "date_of_birth": date(1983, 12, 3), + "phone": "555-0203", + "city": "Ottawa", + "province": "Ontario", + "postal_code": "K1P 1J1", + "gender_identity": "Man", + "pronouns": ["he", "him"], + "ethnic_group": ["Black/African"], + "marital_status": "Single", + "has_kids": "No", + "diagnosis": "Hodgkin Lymphoma", + "date_of_diagnosis": date(2020, 2, 14), + "has_blood_cancer": "Yes", + "caring_for_someone": "No", + }, + "treatments": [3, 6], # Chemotherapy, Radiation + "experiences": [11, 12, 8], # Anxiety/Depression, PTSD, Returning to work + }, + + # High-matching volunteers for Sarah Johnson + { + "role": "volunteer", + "user_data": { + "first_name": "Emily", + "last_name": "Chen", + "email": "emily.chen@example.com", + "auth_id": "auth_emily_007", + "date_of_birth": date(1984, 7, 22), # Similar age to Sarah (1985) + "phone": "555-0301", + "city": "Toronto", # Same city as Sarah + "province": "Ontario", # Same province as Sarah + "postal_code": "M5V 3B2", + "gender_identity": "Woman", # Same as Sarah + "pronouns": ["she", "her"], + "ethnic_group": ["Asian"], + "marital_status": "Married", # Same as Sarah + "has_kids": "Yes", # Same as Sarah + "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! + "date_of_diagnosis": date(2019, 5, 10), # Survivor + "has_blood_cancer": "Yes", + "caring_for_someone": "No", + }, + "treatments": [3, 6], # Chemotherapy, Radiation (matching Sarah's preferences) + "experiences": [1, 4, 5], # Brain Fog, Feeling Overwhelmed, Fatigue (same as Sarah!) + }, + { + "role": "volunteer", + "user_data": { + "first_name": "Lisa", + "last_name": "Rodriguez", + "email": "lisa.rodriguez@example.com", + "auth_id": "auth_lisa_008", + "date_of_birth": date(1987, 2, 14), # Similar age to Sarah + "phone": "555-0302", + "city": "Toronto", # Same city as Sarah + "province": "Ontario", # Same province as Sarah + "postal_code": "M4W 2K5", + "gender_identity": "Woman", # Same as Sarah + "pronouns": ["she", "her"], + "ethnic_group": ["Hispanic/Latino"], + "marital_status": "Married", # Same as Sarah + "has_kids": "Yes", # Same as Sarah + "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! + "date_of_diagnosis": date(2021, 3, 18), # Survivor + "has_blood_cancer": "Yes", + "caring_for_someone": "No", + }, + "treatments": [3, 6], # Chemotherapy, Radiation (matching Sarah's preferences) + "experiences": [1, 4, 5, 11], # Brain Fog, Feeling Overwhelmed, Fatigue, Anxiety/Depression + }, + { + "role": "volunteer", + "user_data": { + "first_name": "Amanda", + "last_name": "Taylor", + "email": "amanda.taylor@example.com", + "auth_id": "auth_amanda_009", + "date_of_birth": date(1983, 11, 8), # Similar age to Sarah + "phone": "555-0303", + "city": "Mississauga", # Close to Toronto + "province": "Ontario", # Same province as Sarah + "postal_code": "L5B 3C1", + "gender_identity": "Woman", # Same as Sarah + "pronouns": ["she", "her"], + "ethnic_group": ["White/Caucasian"], + "marital_status": "Married", # Same as Sarah + "has_kids": "Yes", # Same as Sarah + "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! + "date_of_diagnosis": date(2018, 9, 25), # Survivor + "has_blood_cancer": "Yes", + "caring_for_someone": "No", + }, + "treatments": [3, 6, 7], # Chemotherapy, Radiation, Maintenance Chemo + "experiences": [1, 4, 5], # Brain Fog, Feeling Overwhelmed, Fatigue (same as Sarah!) + }, + + # Test Case 3: Participant who is a caregiver wanting caregiver volunteers + { + "role": "participant", + "user_data": { + "first_name": "Karen", + "last_name": "Davis", + "email": "karen.davis@example.com", + "auth_id": "auth_karen_010", + "date_of_birth": date(1978, 4, 12), + "phone": "555-0401", + "city": "Toronto", + "province": "Ontario", + "postal_code": "M6K 3M2", + "gender_identity": "Woman", + "pronouns": ["she", "her"], + "ethnic_group": ["White/Caucasian"], + "marital_status": "Married", + "has_kids": "Yes", + "has_blood_cancer": "No", # Not a cancer patient herself + "caring_for_someone": "Yes", # Is a caregiver + "loved_one_gender_identity": "Woman", + "loved_one_age": "45", + "loved_one_diagnosis": "Breast Cancer", + "loved_one_date_of_diagnosis": date(2023, 2, 20), + }, + "treatments": [], # Caregiver, no personal treatments + "experiences": [ExperienceId.COMPASSION_FATIGUE, ExperienceId.FEELING_OVERWHELMED, ExperienceId.ANXIETY_DEPRESSION], + } + ] + + created_users = [] + + # Create users and their data + for user_info in users_data: + role_id = 1 if user_info["role"] == "participant" else 2 + + # Check if user already exists + existing_user = session.query(User).filter_by(email=user_info["user_data"]["email"]).first() + if existing_user: + print(f"User already exists: {user_info['user_data']['email']}") + continue + + # Create user + user = User( + id=uuid.uuid4(), + first_name=user_info["user_data"]["first_name"], + last_name=user_info["user_data"]["last_name"], + email=user_info["user_data"]["email"], + role_id=role_id, + auth_id=user_info["user_data"]["auth_id"], + approved=True, + active=True + ) + session.add(user) + session.flush() # Get user ID + + # Create user data + user_data = UserData( + user_id=user.id, + **{k: v for k, v in user_info["user_data"].items() + if k not in ["first_name", "last_name", "email", "auth_id"]} + ) + session.add(user_data) + + # Add treatments if they exist + if user_info["treatments"]: + treatments = session.query(Treatment).filter(Treatment.id.in_(user_info["treatments"])).all() + user_data.treatments = treatments + + # Add experiences if they exist + if user_info["experiences"]: + experiences = session.query(Experience).filter(Experience.id.in_(user_info["experiences"])).all() + user_data.experiences = experiences + + created_users.append((user, user_info["role"])) + print(f"Added {user_info['role']}: {user.first_name} {user.last_name}") + + session.commit() diff --git a/backend/app/server.py b/backend/app/server.py index eb1f695c..41def270 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, intake, match, ranking, send_email, suggested_times, test, user +from .routes import auth, availability, intake, match, matching, ranking, send_email, suggested_times, test, user from .utilities.constants import LOGGER_NAME from .utilities.firebase_init import initialize_firebase from .utilities.ses.ses_init import ensure_ses_templates @@ -29,6 +29,7 @@ "/health", "/test-middleware-public", "/email/send-test-email", + "/matching/{user_id}", ] @@ -67,6 +68,7 @@ async def lifespan(_: FastAPI): app.include_router(availability.router) app.include_router(suggested_times.router) app.include_router(match.router) +app.include_router(matching.router) app.include_router(intake.router) app.include_router(ranking.router) app.include_router(send_email.router) diff --git a/backend/app/services/implementations/matching_service.py b/backend/app/services/implementations/matching_service.py new file mode 100644 index 00000000..5637149c --- /dev/null +++ b/backend/app/services/implementations/matching_service.py @@ -0,0 +1,453 @@ +import logging +import math +from datetime import date +from typing import Any, Dict, List, Optional +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.interfaces.matching_service import IMatchingService +from app.models.Experience import Experience +from app.models.Quality import Quality +from app.models.RankingPreference import RankingPreference +from app.models.Role import Role +from app.models.Treatment import Treatment +from app.models.User import User +from app.models.UserData import UserData +from app.schemas.user import UserBase, UserRole + + +class MatchingService(IMatchingService): + def __init__(self, db: Session): + self.db = db + self.logger = logging.getLogger(__name__) + + async def get_matches(self, participant_id: UUID, limit: Optional[int] = 5) -> List[Dict[str, Any]]: + """ + Find potential volunteer matches for a participant using complex ranking preferences. + :param participant_id: ID of the participant user to find matches for + :param limit: Maximum number of matches to return (default 5) + :return: List of dictionaries with 'user' and 'score' keys + :raises ValueError: If user is not found or not a participant + """ + try: + # Get the participant user + user = self.db.query(User).filter(User.id == participant_id).first() + if not user: + raise ValueError(f"User with ID {participant_id} not found") + + # Verify this is a participant + if user.role.name != UserRole.PARTICIPANT: + raise ValueError(f"User with ID {participant_id} is not a participant") + + # Get participant's UserData + participant_data = self.db.query(UserData).filter(UserData.user_id == participant_id).first() + if not participant_data: + raise ValueError(f"User with ID {participant_id} has no intake form data") + + # Get participant's ranking preferences + participant_preferences = self._get_user_preferences(participant_id) + if not participant_preferences: + raise ValueError(f"User with ID {participant_id} has no ranking form data") + + # Get all active, approved volunteers with their data + volunteers_with_data = ( + self.db.query(User, UserData) + .join(User.role) + .join(UserData, User.id == UserData.user_id) + .filter(Role.name == UserRole.VOLUNTEER) + .filter(User.active) + .filter(User.approved) + .all() + ) + + if not volunteers_with_data: + return [] + + # Calculate scores for each volunteer + scored_volunteers = [] + for volunteer_user, volunteer_data in volunteers_with_data: + # print(f"\n=== COMPARING: Participant vs {volunteer_user.first_name} {volunteer_user.last_name} ===") + # self._print_comparison_table(participant_data, volunteer_data, volunteer_user.first_name + " " + volunteer_user.last_name, participant_preferences) + score = self._calculate_match_score(participant_data, volunteer_data, participant_preferences) + # print(f"FINAL SCORE: {score}") + scored_volunteers.append((volunteer_user, score)) + + # Sort by score (highest first) and apply limit + scored_volunteers.sort(key=lambda x: x[1], reverse=True) + if limit: + scored_volunteers = scored_volunteers[:limit] + + + # Convert to response models with scores + return [ + { + 'user': UserBase( + first_name=volunteer.first_name, + last_name=volunteer.last_name, + email=volunteer.email, + role=UserRole(volunteer.role.name) + ), + 'score': score + } + for volunteer, score in scored_volunteers + ] + + except ValueError as ve: + raise ve + except Exception as e: + self.logger.error(f"Error finding matches: {str(e)}") + raise HTTPException(status_code=500, detail=f"Internal server error during matching process: {str(e)}") + + def _get_user_preferences(self, user_id: UUID) -> List[Dict[str, Any]]: + """Get user's ranking preferences with full context.""" + preferences = ( + self.db.query(RankingPreference) + .filter(RankingPreference.user_id == user_id) + .order_by(RankingPreference.rank) + .all() + ) + + preference_data = [] + for pref in preferences: + pref_info = { + 'target_role': pref.target_role, + 'kind': pref.kind, + 'scope': pref.scope, + 'rank': pref.rank, + 'object': None + } + + # Get the actual object based on kind + if pref.kind == 'quality' and pref.quality_id: + quality = self.db.query(Quality).filter(Quality.id == pref.quality_id).first() + pref_info['object'] = quality + elif pref.kind == 'treatment' and pref.treatment_id: + treatment = self.db.query(Treatment).filter(Treatment.id == pref.treatment_id).first() + pref_info['object'] = treatment + elif pref.kind == 'experience' and pref.experience_id: + experience = self.db.query(Experience).filter(Experience.id == pref.experience_id).first() + pref_info['object'] = experience + + if pref_info['object']: + preference_data.append(pref_info) + + return preference_data + + def _calculate_match_score(self, participant_data: UserData, volunteer_data: UserData, preferences: List[Dict[str, Any]]) -> float: + """ + Calculate match score using complex preference system with target roles, kinds, and scopes. + """ + + # Group preferences by target role + patient_prefs = [p for p in preferences if p['target_role'] == 'patient'] + caregiver_prefs = [p for p in preferences if p['target_role'] == 'caregiver'] + + # Check user conditions directly + has_cancer = (participant_data.has_blood_cancer or "").lower() == "yes" + is_caregiver = (participant_data.caring_for_someone or "").lower() == "yes" + wants_patient = has_cancer and not is_caregiver + + # Case 1: Participant (patient) wants cancer patient volunteer + if patient_prefs and wants_patient: + return self._score_preferences(patient_prefs, participant_data, volunteer_data, + "self", "self") + + # Case 2: Participant (caregiver) wants ONLY cancer patient volunteers + # Match caregiver's loved one data with volunteer's patient data + if patient_prefs and is_caregiver and not caregiver_prefs: + return self._score_preferences(patient_prefs, participant_data, volunteer_data, + "loved_one", "self") + + # Case 3: Participant (caregiver) wants caregiver volunteers + # Match based on both patient and caregiver qualities + if caregiver_prefs and is_caregiver and not patient_prefs: + return self._score_preferences(caregiver_prefs, participant_data, volunteer_data, + "self", "self") + self._score_preferences(patient_prefs, participant_data, volunteer_data, + "loved_one", "loved_one") + + return 0.0 + + def _print_comparison_table(self, participant_data: UserData, volunteer_data: UserData, volunteer_name: str, preferences: List[Dict[str, Any]]) -> None: + """Print a side-by-side comparison table of participant vs volunteer data with preference indicators.""" + + print(f"{'Field':<25} | {'Participant':<30} | {volunteer_name:<30}") + print("-" * 90) + + # Extract preference information for marking + preference_fields = set() + for pref in preferences: + obj = pref.get('object') + kind = pref.get('kind') + if obj and kind == 'quality': + preference_fields.add(obj.slug) + elif obj and kind == 'treatment': + preference_fields.add(f"treatment_{obj.name}") + elif obj and kind == 'experience': + preference_fields.add(f"experience_{obj.name}") + + # Key matching fields + fields = [ + ("Gender", participant_data.gender_identity, volunteer_data.gender_identity), + ("Diagnosis", participant_data.diagnosis, volunteer_data.diagnosis), + ("Date of Birth", str(participant_data.date_of_birth), str(volunteer_data.date_of_birth)), + ("Marital Status", participant_data.marital_status, volunteer_data.marital_status), + ("Has Kids", participant_data.has_kids, volunteer_data.has_kids), + ("City", participant_data.city, volunteer_data.city), + ("Province", participant_data.province, volunteer_data.province), + ("Has Blood Cancer", participant_data.has_blood_cancer, volunteer_data.has_blood_cancer), + ("Caring for Someone", participant_data.caring_for_someone, volunteer_data.caring_for_someone), + ] + + # Add loved one fields if participant is caregiver + if (participant_data.caring_for_someone or "").lower() == "yes": + fields.append(("--- LOVED ONE DATA ---", "Participant Loved One", "Volunteer Loved One")) + loved_one_fields = [ + ("LO Gender", participant_data.loved_one_gender_identity, volunteer_data.loved_one_gender_identity), + ("LO Age", participant_data.loved_one_age, volunteer_data.loved_one_age), + ("LO Diagnosis", participant_data.loved_one_diagnosis, volunteer_data.loved_one_diagnosis), + ("LO Date Diagnosis", str(participant_data.loved_one_date_of_diagnosis), str(volunteer_data.loved_one_date_of_diagnosis)), + ] + fields.extend(loved_one_fields) + + for field_name, participant_val, volunteer_val in fields: + participant_str = str(participant_val) if participant_val is not None else "None" + volunteer_str = str(volunteer_val) if volunteer_val is not None else "None" + + # Check if this field is a preference + is_preference = False + if field_name == "Gender" and "same_gender_identity" in preference_fields: + is_preference = True + elif field_name == "Diagnosis" and "same_diagnosis" in preference_fields: + is_preference = True + elif field_name == "Date of Birth" and "same_age" in preference_fields: + is_preference = True + elif field_name == "Marital Status" and "same_marital_status" in preference_fields: + is_preference = True + elif field_name == "Has Kids" and "same_parental_status" in preference_fields: + is_preference = True + + # Indicators + pref_indicator = " *PREF*" if is_preference else "" + match_indicator = " *MATCH*" if participant_val == volunteer_val and participant_val is not None else "" + + print(f"{field_name:<25} | {participant_str:<30} | {volunteer_str:<30}{pref_indicator}{match_indicator}") + + def _userdata_to_dict(self, user_data: UserData) -> dict: + """Convert UserData object to dictionary for JSON serialization.""" + + def serialize_value(value): + if isinstance(value, date): + return value.isoformat() + elif value is None: + return None + else: + return str(value) + + return { + "gender_identity": serialize_value(user_data.gender_identity), + "diagnosis": serialize_value(user_data.diagnosis), + "date_of_birth": serialize_value(user_data.date_of_birth), + "date_of_diagnosis": serialize_value(user_data.date_of_diagnosis), + "marital_status": serialize_value(user_data.marital_status), + "has_kids": serialize_value(user_data.has_kids), + "has_blood_cancer": serialize_value(user_data.has_blood_cancer), + "caring_for_someone": serialize_value(user_data.caring_for_someone), + "city": serialize_value(user_data.city), + "province": serialize_value(user_data.province), + "loved_one_gender_identity": serialize_value(user_data.loved_one_gender_identity), + "loved_one_age": serialize_value(user_data.loved_one_age), + "loved_one_diagnosis": serialize_value(user_data.loved_one_diagnosis), + "loved_one_date_of_diagnosis": serialize_value(user_data.loved_one_date_of_diagnosis), + } + + def _score_preferences(self, preferences: List[Dict[str, Any]], participant_data: UserData, volunteer_data: UserData, + participant_scope: str, volunteer_scope: str) -> float: + """Score preferences with explicit participant and volunteer scopes.""" + if not preferences: + return 0.0 + + total_score = 0.0 + max_possible_score = 0.0 + + for pref in preferences: + # Calculate preference weight (higher rank = lower weight) + rank = pref['rank'] + weight = 1.0 / rank # Simple inverse ranking + + # Check if volunteer matches this preference using explicit scopes + match_score = self._check_preference_match( + participant_data, volunteer_data, pref, participant_scope, volunteer_scope + ) + + # Handle both boolean and float returns + if isinstance(match_score, bool): + match_score = 1.0 if match_score else 0.0 + + total_score += weight * match_score + + max_possible_score += weight + + # Return normalized score (0.0 to 1.0) + return total_score / max_possible_score if max_possible_score > 0 else 0.0 + + + def _check_preference_match(self, participant_data: UserData, volunteer_data: UserData, + preference: Dict[str, Any], participant_scope: str, volunteer_scope: str) -> bool: + """Check if volunteer matches a preference using explicit scopes for participant and volunteer.""" + obj = preference['object'] + kind = preference['kind'] + + if kind == 'quality': + return self._check_quality_match(participant_data, volunteer_data, obj, participant_scope, volunteer_scope) + elif kind == 'treatment': + return self._check_treatment_match(volunteer_data, obj, volunteer_scope) + elif kind == 'experience': + return self._check_experience_match(volunteer_data, obj, volunteer_scope) + + return False + + def _check_quality_match(self, participant_data: UserData, volunteer_data: UserData, + quality: Quality, participant_scope: str, volunteer_scope: str) -> bool: + """Check if volunteer matches a quality preference with explicit scopes.""" + quality_slug = quality.slug + + # Get participant value based on participant_scope + if participant_scope == "self": + if quality_slug == "same_gender_identity": + participant_value = participant_data.gender_identity + elif quality_slug == "same_diagnosis": + participant_value = participant_data.diagnosis + elif quality_slug == "same_age": + if participant_data.date_of_birth: + participant_value = date.today().year - participant_data.date_of_birth.year + else: + participant_value = None + elif quality_slug == "same_marital_status": + participant_value = participant_data.marital_status + elif quality_slug == "same_parental_status": + participant_value = participant_data.has_kids + else: + return False + elif participant_scope == "loved_one": + if quality_slug == "same_gender_identity": + participant_value = participant_data.loved_one_gender_identity + elif quality_slug == "same_diagnosis": + participant_value = participant_data.loved_one_diagnosis + elif quality_slug == "same_age": + participant_value = participant_data.loved_one_age # This is age range, needs special handling + else: + return False + else: + return False + + # Get volunteer value based on volunteer_scope + if volunteer_scope == "self": + if quality_slug == "same_gender_identity": + volunteer_value = volunteer_data.gender_identity + elif quality_slug == "same_diagnosis": + volunteer_value = volunteer_data.diagnosis + elif quality_slug == "same_age": + if volunteer_data.date_of_birth: + volunteer_value = date.today().year - volunteer_data.date_of_birth.year + else: + volunteer_value = None + elif quality_slug == "same_marital_status": + volunteer_value = volunteer_data.marital_status + elif quality_slug == "same_parental_status": + volunteer_value = volunteer_data.has_kids + else: + return False + elif volunteer_scope == "loved_one": + if quality_slug == "same_gender_identity": + volunteer_value = volunteer_data.loved_one_gender_identity + elif quality_slug == "same_diagnosis": + volunteer_value = volunteer_data.loved_one_diagnosis + elif quality_slug == "same_age": + volunteer_value = volunteer_data.loved_one_age # This is age range, needs special handling + elif quality_slug == "same_marital_status": + volunteer_value = None + elif quality_slug == "same_parental_status": + volunteer_value = None + else: + return False + else: + return False + + # Compare values + if quality_slug == "same_age": + return self._check_age_similarity(participant_value, volunteer_value) + else: + return participant_value == volunteer_value + + def _check_treatment_match(self, volunteer_data: UserData, + treatment: Treatment, volunteer_scope: str) -> bool: + """Check if volunteer has experience with the specific treatment.""" + treatment_name = treatment.name + + # Check if volunteer has experience with this treatment (based on their scope) + if volunteer_scope == 'self': + volunteer_treatments = {t.name for t in volunteer_data.treatments} + return treatment_name in volunteer_treatments + elif volunteer_scope == 'loved_one': + volunteer_treatments = {t.name for t in volunteer_data.loved_one_treatments} + return treatment_name in volunteer_treatments + + return False + + def _check_experience_match(self, volunteer_data: UserData, + experience: Experience, volunteer_scope: str) -> bool: + """Check if volunteer has experience with the specific experience.""" + experience_name = experience.name + + # Check if volunteer has experience with this (based on their scope) + if volunteer_scope == 'self': + volunteer_experiences = {e.name for e in volunteer_data.experiences} + return experience_name in volunteer_experiences + elif volunteer_scope == 'loved_one': + volunteer_loved_experiences = {e.name for e in volunteer_data.loved_one_experiences} + return experience_name in volunteer_loved_experiences + + return False + + def _softmax(self, values: List[float]) -> List[float]: + """Apply softmax function to convert values to probabilities.""" + if not values: + return [] + + # Subtract max for numerical stability + max_val = max(values) + exp_values = [math.exp(v - max_val) for v in values] + sum_exp = sum(exp_values) + + if sum_exp == 0: + return [1.0 / len(values)] * len(values) + + return [exp_val / sum_exp for exp_val in exp_values] + + def _check_age_similarity(self, participant_age, volunteer_age) -> float: + """Calculate age similarity from age values directly.""" + if not participant_age or not volunteer_age: + return 0.0 + + # Convert string ages to integers + if isinstance(participant_age, str): + try: + participant_age = int(participant_age) + except ValueError: + return 0.0 + if isinstance(volunteer_age, str): + try: + volunteer_age = int(volunteer_age) + except ValueError: + return 0.0 + + if participant_age <= 0: + return 0.0 + + age_diff = abs(participant_age - volunteer_age) + similarity_ratio = 1.0 - (age_diff / participant_age) + + # Ensure score is between 0.0 and 1.0 + return max(0.0, min(1.0, similarity_ratio)) diff --git a/backend/app/utilities/db_utils.py b/backend/app/utilities/db_utils.py index 6edaa687..a7535d2c 100644 --- a/backend/app/utilities/db_utils.py +++ b/backend/app/utilities/db_utils.py @@ -6,12 +6,12 @@ load_dotenv() -DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") +DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL") if not DATABASE_URL: raise RuntimeError( - "POSTGRES_TEST_DATABASE_URL is not set. " + "POSTGRES_DATABASE_URL is not set. " "Set one of them to a valid Postgres URL, e.g. " - "postgresql+psycopg2://postgres:postgres@db:5432/llsc_test" + "postgresql://postgres:postgres@localhost:5432/llsc" ) engine = create_engine(DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/backend/app/utilities/form_constants.py b/backend/app/utilities/form_constants.py new file mode 100644 index 00000000..b0e0754e --- /dev/null +++ b/backend/app/utilities/form_constants.py @@ -0,0 +1,42 @@ +# Quality IDs +class QualityId: + SAME_AGE = 1 + SAME_GENDER_IDENTITY = 2 + SAME_ETHNIC_OR_CULTURAL_GROUP = 3 + SAME_MARITAL_STATUS = 4 + SAME_PARENTAL_STATUS = 5 + SAME_DIAGNOSIS = 6 + + +# Treatment IDs +class TreatmentId: + UNKNOWN = 1 + WATCH_AND_WAIT = 2 + CHEMOTHERAPY = 3 + IMMUNOTHERAPY = 4 + ORAL_CHEMOTHERAPY = 5 + RADIATION = 6 + MAINTENANCE_CHEMOTHERAPY = 7 + PALLIATIVE_CARE = 8 + TRANSFUSIONS = 9 + AUTOLOGOUS_STEM_CELL_TRANSPLANT = 10 + ALLOGENEIC_STEM_CELL_TRANSPLANT = 11 + HAPLO_STEM_CELL_TRANSPLANT = 12 + CAR_T = 13 + BTK_INHIBITORS = 14 + + +# Experience IDs +class ExperienceId: + BRAIN_FOG = 1 + COMMUNICATION_CHALLENGES = 2 + COMPASSION_FATIGUE = 3 + FEELING_OVERWHELMED = 4 + FATIGUE = 5 + FERTILITY_ISSUES = 6 + GRAFT_VS_HOST = 7 + RETURNING_TO_WORK = 8 + SPEAKING_TO_FAMILY = 9 + RELAPSE = 10 + ANXIETY_DEPRESSION = 11 + PTSD = 12 From 6a502ab1a0224064ff70b1ff28fdafd4eb5388b0 Mon Sep 17 00:00:00 2001 From: Evan Wu Date: Thu, 18 Sep 2025 01:32:20 +0800 Subject: [PATCH 2/4] undid change --- backend/app/middleware/auth_middleware.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/backend/app/middleware/auth_middleware.py b/backend/app/middleware/auth_middleware.py index 2f103f6a..8f8e52c4 100644 --- a/backend/app/middleware/auth_middleware.py +++ b/backend/app/middleware/auth_middleware.py @@ -17,15 +17,14 @@ def __init__(self, app: ASGIApp, public_paths: List[str] = None): self.logger = logging.getLogger(LOGGER_NAME("auth_middleware")) def is_public_path(self, path: str) -> bool: - return True - # for public_path in self.public_paths: - # # Handle parameterized routes by checking if path starts with the pattern - # if public_path.endswith("{email}") and path.startswith(public_path.replace("{email}", "")): - # return True - # # Exact match for non-parameterized routes - # if path == public_path: - # return True - # return False + for public_path in self.public_paths: + # Handle parameterized routes by checking if path starts with the pattern + if public_path.endswith("{email}") and path.startswith(public_path.replace("{email}", "")): + return True + # Exact match for non-parameterized routes + if path == public_path: + return True + return False async def dispatch(self, request: Request, call_next): # Allow preflight CORS requests to pass through without auth From c9d3cf4544ddb870abdc783e19b5acd54fb287dc Mon Sep 17 00:00:00 2001 From: Evan Wu Date: Thu, 18 Sep 2025 01:37:17 +0800 Subject: [PATCH 3/4] ran fmt --- backend/app/routes/matching.py | 7 +- backend/app/schemas/matching.py | 2 + backend/app/seeds/ranking_preferences.py | 60 ++++---- backend/app/seeds/users.py | 39 ++++-- .../implementations/matching_service.py | 132 +++++++++++------- 5 files changed, 144 insertions(+), 96 deletions(-) diff --git a/backend/app/routes/matching.py b/backend/app/routes/matching.py index 252baaf0..30f2e76c 100644 --- a/backend/app/routes/matching.py +++ b/backend/app/routes/matching.py @@ -12,14 +12,13 @@ tags=["matching"], ) + def get_matching_service(db: Session = Depends(get_db)): return MatchingService(db) + @router.get("/{user_id}", response_model=RelevantUsersResponse) -async def get_matches( - user_id: UUID, - matching_service: MatchingService = Depends(get_matching_service) -): +async def get_matches(user_id: UUID, matching_service: MatchingService = Depends(get_matching_service)): """ Get potential user matches based on the user's profile. """ diff --git a/backend/app/schemas/matching.py b/backend/app/schemas/matching.py index 96eae8c3..fe8b11c0 100644 --- a/backend/app/schemas/matching.py +++ b/backend/app/schemas/matching.py @@ -13,6 +13,7 @@ class MatchedUser(BaseModel): """ Schema for a matched user with their compatibility score. """ + user: UserBase score: float @@ -21,4 +22,5 @@ class RelevantUsersResponse(BaseModel): """ Response schema for matching endpoint containing a list of relevant users with scores. """ + matches: List[MatchedUser] diff --git a/backend/app/seeds/ranking_preferences.py b/backend/app/seeds/ranking_preferences.py index 76755957..e13bf3bc 100644 --- a/backend/app/seeds/ranking_preferences.py +++ b/backend/app/seeds/ranking_preferences.py @@ -32,7 +32,7 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "quality", "quality_id": QualityId.SAME_DIAGNOSIS, "scope": "self", - "rank": 1 + "rank": 1, }, { "user_id": sarah_id, @@ -40,7 +40,7 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "quality", "quality_id": QualityId.SAME_GENDER_IDENTITY, "scope": "self", - "rank": 2 + "rank": 2, }, { "user_id": sarah_id, @@ -48,7 +48,7 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "quality", "quality_id": QualityId.SAME_AGE, "scope": "self", - "rank": 3 + "rank": 3, }, { "user_id": sarah_id, @@ -56,7 +56,7 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "treatment", "treatment_id": TreatmentId.CHEMOTHERAPY, "scope": "self", - "rank": 4 + "rank": 4, }, { "user_id": sarah_id, @@ -64,9 +64,8 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "treatment", "treatment_id": TreatmentId.RADIATION, "scope": "self", - "rank": 5 + "rank": 5, }, - # CASE 2: Lisa (caregiver) wants ONLY patient volunteers - 5 preferences { "user_id": lisa_id, @@ -74,7 +73,7 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "quality", "quality_id": QualityId.SAME_DIAGNOSIS, "scope": "loved_one", - "rank": 1 + "rank": 1, }, { "user_id": lisa_id, @@ -82,7 +81,7 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "quality", "quality_id": QualityId.SAME_GENDER_IDENTITY, "scope": "loved_one", - "rank": 2 + "rank": 2, }, { "user_id": lisa_id, @@ -90,7 +89,7 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "quality", "quality_id": QualityId.SAME_AGE, "scope": "loved_one", - "rank": 3 + "rank": 3, }, { "user_id": lisa_id, @@ -98,7 +97,7 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "treatment", "treatment_id": TreatmentId.CHEMOTHERAPY, "scope": "loved_one", - "rank": 4 + "rank": 4, }, { "user_id": lisa_id, @@ -106,9 +105,8 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "treatment", "treatment_id": TreatmentId.RADIATION, "scope": "loved_one", - "rank": 5 + "rank": 5, }, - # CASE 3: Karen (caregiver) wants ONLY caregiver volunteers - 5 preferences { "user_id": karen_id, @@ -116,7 +114,7 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "quality", "quality_id": QualityId.SAME_MARITAL_STATUS, "scope": "self", - "rank": 1 + "rank": 1, }, { "user_id": karen_id, @@ -124,7 +122,7 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "quality", "quality_id": QualityId.SAME_PARENTAL_STATUS, "scope": "self", - "rank": 2 + "rank": 2, }, { "user_id": karen_id, @@ -132,7 +130,7 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "quality", "quality_id": QualityId.SAME_GENDER_IDENTITY, "scope": "self", - "rank": 3 + "rank": 3, }, { "user_id": karen_id, @@ -140,7 +138,7 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "quality", "quality_id": QualityId.SAME_AGE, "scope": "self", - "rank": 4 + "rank": 4, }, { "user_id": karen_id, @@ -148,9 +146,9 @@ def seed_ranking_preferences(session: Session) -> None: "kind": "quality", "quality_id": QualityId.SAME_DIAGNOSIS, "scope": "loved_one", # Match caregiver's loved one diagnosis with volunteer's loved one - "rank": 5 - } - ] + "rank": 5, + }, + ] for pref_data in ranking_data: # Skip Karen's preferences if she doesn't exist yet @@ -158,18 +156,26 @@ def seed_ranking_preferences(session: Session) -> None: continue # Check if preference already exists - existing_pref = session.query(RankingPreference).filter_by( - user_id=pref_data["user_id"], - target_role=pref_data["target_role"], - kind=pref_data["kind"], - rank=pref_data["rank"] - ).first() + existing_pref = ( + session.query(RankingPreference) + .filter_by( + user_id=pref_data["user_id"], + target_role=pref_data["target_role"], + kind=pref_data["kind"], + rank=pref_data["rank"], + ) + .first() + ) if not existing_pref: preference = RankingPreference(**pref_data) session.add(preference) - print(f"Added ranking preference: {pref_data['kind']} rank {pref_data['rank']} for user {pref_data['user_id']}") + print( + f"Added ranking preference: {pref_data['kind']} rank {pref_data['rank']} for user {pref_data['user_id']}" + ) else: - print(f"Ranking preference already exists: {pref_data['kind']} rank {pref_data['rank']} for user {pref_data['user_id']}") + print( + f"Ranking preference already exists: {pref_data['kind']} rank {pref_data['rank']} for user {pref_data['user_id']}" + ) session.commit() diff --git a/backend/app/seeds/users.py b/backend/app/seeds/users.py index 353c5854..59cc08f9 100644 --- a/backend/app/seeds/users.py +++ b/backend/app/seeds/users.py @@ -93,9 +93,12 @@ def seed_users(session: Session) -> None: "loved_one_date_of_diagnosis": date(2023, 12, 15), }, "treatments": [], # Caregiver, no personal treatments - "experiences": [ExperienceId.COMPASSION_FATIGUE, ExperienceId.FEELING_OVERWHELMED, ExperienceId.SPEAKING_TO_FAMILY], + "experiences": [ + ExperienceId.COMPASSION_FATIGUE, + ExperienceId.FEELING_OVERWHELMED, + ExperienceId.SPEAKING_TO_FAMILY, + ], }, - # Volunteers { "role": "volunteer", @@ -119,8 +122,17 @@ def seed_users(session: Session) -> None: "has_blood_cancer": "Yes", "caring_for_someone": "No", }, - "treatments": [TreatmentId.CHEMOTHERAPY, TreatmentId.RADIATION, TreatmentId.AUTOLOGOUS_STEM_CELL_TRANSPLANT], - "experiences": [ExperienceId.BRAIN_FOG, ExperienceId.FEELING_OVERWHELMED, ExperienceId.FATIGUE, ExperienceId.RETURNING_TO_WORK], + "treatments": [ + TreatmentId.CHEMOTHERAPY, + TreatmentId.RADIATION, + TreatmentId.AUTOLOGOUS_STEM_CELL_TRANSPLANT, + ], + "experiences": [ + ExperienceId.BRAIN_FOG, + ExperienceId.FEELING_OVERWHELMED, + ExperienceId.FATIGUE, + ExperienceId.RETURNING_TO_WORK, + ], }, { "role": "volunteer", @@ -172,7 +184,6 @@ def seed_users(session: Session) -> None: "treatments": [3, 6], # Chemotherapy, Radiation "experiences": [11, 12, 8], # Anxiety/Depression, PTSD, Returning to work }, - # High-matching volunteers for Sarah Johnson { "role": "volunteer", @@ -249,7 +260,6 @@ def seed_users(session: Session) -> None: "treatments": [3, 6, 7], # Chemotherapy, Radiation, Maintenance Chemo "experiences": [1, 4, 5], # Brain Fog, Feeling Overwhelmed, Fatigue (same as Sarah!) }, - # Test Case 3: Participant who is a caregiver wanting caregiver volunteers { "role": "participant", @@ -276,8 +286,12 @@ def seed_users(session: Session) -> None: "loved_one_date_of_diagnosis": date(2023, 2, 20), }, "treatments": [], # Caregiver, no personal treatments - "experiences": [ExperienceId.COMPASSION_FATIGUE, ExperienceId.FEELING_OVERWHELMED, ExperienceId.ANXIETY_DEPRESSION], - } + "experiences": [ + ExperienceId.COMPASSION_FATIGUE, + ExperienceId.FEELING_OVERWHELMED, + ExperienceId.ANXIETY_DEPRESSION, + ], + }, ] created_users = [] @@ -301,7 +315,7 @@ def seed_users(session: Session) -> None: role_id=role_id, auth_id=user_info["user_data"]["auth_id"], approved=True, - active=True + active=True, ) session.add(user) session.flush() # Get user ID @@ -309,8 +323,11 @@ def seed_users(session: Session) -> None: # Create user data user_data = UserData( user_id=user.id, - **{k: v for k, v in user_info["user_data"].items() - if k not in ["first_name", "last_name", "email", "auth_id"]} + **{ + k: v + for k, v in user_info["user_data"].items() + if k not in ["first_name", "last_name", "email", "auth_id"] + }, ) session.add(user_data) diff --git a/backend/app/services/implementations/matching_service.py b/backend/app/services/implementations/matching_service.py index 5637149c..b9c6598c 100644 --- a/backend/app/services/implementations/matching_service.py +++ b/backend/app/services/implementations/matching_service.py @@ -79,17 +79,16 @@ async def get_matches(self, participant_id: UUID, limit: Optional[int] = 5) -> L if limit: scored_volunteers = scored_volunteers[:limit] - # Convert to response models with scores return [ { - 'user': UserBase( + "user": UserBase( first_name=volunteer.first_name, last_name=volunteer.last_name, email=volunteer.email, - role=UserRole(volunteer.role.name) + role=UserRole(volunteer.role.name), ), - 'score': score + "score": score, } for volunteer, score in scored_volunteers ] @@ -112,37 +111,39 @@ def _get_user_preferences(self, user_id: UUID) -> List[Dict[str, Any]]: preference_data = [] for pref in preferences: pref_info = { - 'target_role': pref.target_role, - 'kind': pref.kind, - 'scope': pref.scope, - 'rank': pref.rank, - 'object': None + "target_role": pref.target_role, + "kind": pref.kind, + "scope": pref.scope, + "rank": pref.rank, + "object": None, } # Get the actual object based on kind - if pref.kind == 'quality' and pref.quality_id: + if pref.kind == "quality" and pref.quality_id: quality = self.db.query(Quality).filter(Quality.id == pref.quality_id).first() - pref_info['object'] = quality - elif pref.kind == 'treatment' and pref.treatment_id: + pref_info["object"] = quality + elif pref.kind == "treatment" and pref.treatment_id: treatment = self.db.query(Treatment).filter(Treatment.id == pref.treatment_id).first() - pref_info['object'] = treatment - elif pref.kind == 'experience' and pref.experience_id: + pref_info["object"] = treatment + elif pref.kind == "experience" and pref.experience_id: experience = self.db.query(Experience).filter(Experience.id == pref.experience_id).first() - pref_info['object'] = experience + pref_info["object"] = experience - if pref_info['object']: + if pref_info["object"]: preference_data.append(pref_info) return preference_data - def _calculate_match_score(self, participant_data: UserData, volunteer_data: UserData, preferences: List[Dict[str, Any]]) -> float: + def _calculate_match_score( + self, participant_data: UserData, volunteer_data: UserData, preferences: List[Dict[str, Any]] + ) -> float: """ Calculate match score using complex preference system with target roles, kinds, and scopes. """ # Group preferences by target role - patient_prefs = [p for p in preferences if p['target_role'] == 'patient'] - caregiver_prefs = [p for p in preferences if p['target_role'] == 'caregiver'] + patient_prefs = [p for p in preferences if p["target_role"] == "patient"] + caregiver_prefs = [p for p in preferences if p["target_role"] == "caregiver"] # Check user conditions directly has_cancer = (participant_data.has_blood_cancer or "").lower() == "yes" @@ -151,25 +152,29 @@ def _calculate_match_score(self, participant_data: UserData, volunteer_data: Use # Case 1: Participant (patient) wants cancer patient volunteer if patient_prefs and wants_patient: - return self._score_preferences(patient_prefs, participant_data, volunteer_data, - "self", "self") + return self._score_preferences(patient_prefs, participant_data, volunteer_data, "self", "self") # Case 2: Participant (caregiver) wants ONLY cancer patient volunteers # Match caregiver's loved one data with volunteer's patient data if patient_prefs and is_caregiver and not caregiver_prefs: - return self._score_preferences(patient_prefs, participant_data, volunteer_data, - "loved_one", "self") + return self._score_preferences(patient_prefs, participant_data, volunteer_data, "loved_one", "self") # Case 3: Participant (caregiver) wants caregiver volunteers # Match based on both patient and caregiver qualities if caregiver_prefs and is_caregiver and not patient_prefs: - return self._score_preferences(caregiver_prefs, participant_data, volunteer_data, - "self", "self") + self._score_preferences(patient_prefs, participant_data, volunteer_data, - "loved_one", "loved_one") + return self._score_preferences( + caregiver_prefs, participant_data, volunteer_data, "self", "self" + ) + self._score_preferences(patient_prefs, participant_data, volunteer_data, "loved_one", "loved_one") return 0.0 - def _print_comparison_table(self, participant_data: UserData, volunteer_data: UserData, volunteer_name: str, preferences: List[Dict[str, Any]]) -> None: + def _print_comparison_table( + self, + participant_data: UserData, + volunteer_data: UserData, + volunteer_name: str, + preferences: List[Dict[str, Any]], + ) -> None: """Print a side-by-side comparison table of participant vs volunteer data with preference indicators.""" print(f"{'Field':<25} | {'Participant':<30} | {volunteer_name:<30}") @@ -178,13 +183,13 @@ def _print_comparison_table(self, participant_data: UserData, volunteer_data: Us # Extract preference information for marking preference_fields = set() for pref in preferences: - obj = pref.get('object') - kind = pref.get('kind') - if obj and kind == 'quality': + obj = pref.get("object") + kind = pref.get("kind") + if obj and kind == "quality": preference_fields.add(obj.slug) - elif obj and kind == 'treatment': + elif obj and kind == "treatment": preference_fields.add(f"treatment_{obj.name}") - elif obj and kind == 'experience': + elif obj and kind == "experience": preference_fields.add(f"experience_{obj.name}") # Key matching fields @@ -207,7 +212,11 @@ def _print_comparison_table(self, participant_data: UserData, volunteer_data: Us ("LO Gender", participant_data.loved_one_gender_identity, volunteer_data.loved_one_gender_identity), ("LO Age", participant_data.loved_one_age, volunteer_data.loved_one_age), ("LO Diagnosis", participant_data.loved_one_diagnosis, volunteer_data.loved_one_diagnosis), - ("LO Date Diagnosis", str(participant_data.loved_one_date_of_diagnosis), str(volunteer_data.loved_one_date_of_diagnosis)), + ( + "LO Date Diagnosis", + str(participant_data.loved_one_date_of_diagnosis), + str(volunteer_data.loved_one_date_of_diagnosis), + ), ] fields.extend(loved_one_fields) @@ -262,8 +271,14 @@ def serialize_value(value): "loved_one_date_of_diagnosis": serialize_value(user_data.loved_one_date_of_diagnosis), } - def _score_preferences(self, preferences: List[Dict[str, Any]], participant_data: UserData, volunteer_data: UserData, - participant_scope: str, volunteer_scope: str) -> float: + def _score_preferences( + self, + preferences: List[Dict[str, Any]], + participant_data: UserData, + volunteer_data: UserData, + participant_scope: str, + volunteer_scope: str, + ) -> float: """Score preferences with explicit participant and volunteer scopes.""" if not preferences: return 0.0 @@ -273,7 +288,7 @@ def _score_preferences(self, preferences: List[Dict[str, Any]], participant_data for pref in preferences: # Calculate preference weight (higher rank = lower weight) - rank = pref['rank'] + rank = pref["rank"] weight = 1.0 / rank # Simple inverse ranking # Check if volunteer matches this preference using explicit scopes @@ -292,24 +307,35 @@ def _score_preferences(self, preferences: List[Dict[str, Any]], participant_data # Return normalized score (0.0 to 1.0) return total_score / max_possible_score if max_possible_score > 0 else 0.0 - - def _check_preference_match(self, participant_data: UserData, volunteer_data: UserData, - preference: Dict[str, Any], participant_scope: str, volunteer_scope: str) -> bool: + def _check_preference_match( + self, + participant_data: UserData, + volunteer_data: UserData, + preference: Dict[str, Any], + participant_scope: str, + volunteer_scope: str, + ) -> bool: """Check if volunteer matches a preference using explicit scopes for participant and volunteer.""" - obj = preference['object'] - kind = preference['kind'] + obj = preference["object"] + kind = preference["kind"] - if kind == 'quality': + if kind == "quality": return self._check_quality_match(participant_data, volunteer_data, obj, participant_scope, volunteer_scope) - elif kind == 'treatment': + elif kind == "treatment": return self._check_treatment_match(volunteer_data, obj, volunteer_scope) - elif kind == 'experience': + elif kind == "experience": return self._check_experience_match(volunteer_data, obj, volunteer_scope) return False - def _check_quality_match(self, participant_data: UserData, volunteer_data: UserData, - quality: Quality, participant_scope: str, volunteer_scope: str) -> bool: + def _check_quality_match( + self, + participant_data: UserData, + volunteer_data: UserData, + quality: Quality, + participant_scope: str, + volunteer_scope: str, + ) -> bool: """Check if volunteer matches a quality preference with explicit scopes.""" quality_slug = quality.slug @@ -381,31 +407,29 @@ def _check_quality_match(self, participant_data: UserData, volunteer_data: UserD else: return participant_value == volunteer_value - def _check_treatment_match(self, volunteer_data: UserData, - treatment: Treatment, volunteer_scope: str) -> bool: + def _check_treatment_match(self, volunteer_data: UserData, treatment: Treatment, volunteer_scope: str) -> bool: """Check if volunteer has experience with the specific treatment.""" treatment_name = treatment.name # Check if volunteer has experience with this treatment (based on their scope) - if volunteer_scope == 'self': + if volunteer_scope == "self": volunteer_treatments = {t.name for t in volunteer_data.treatments} return treatment_name in volunteer_treatments - elif volunteer_scope == 'loved_one': + elif volunteer_scope == "loved_one": volunteer_treatments = {t.name for t in volunteer_data.loved_one_treatments} return treatment_name in volunteer_treatments return False - def _check_experience_match(self, volunteer_data: UserData, - experience: Experience, volunteer_scope: str) -> bool: + def _check_experience_match(self, volunteer_data: UserData, experience: Experience, volunteer_scope: str) -> bool: """Check if volunteer has experience with the specific experience.""" experience_name = experience.name # Check if volunteer has experience with this (based on their scope) - if volunteer_scope == 'self': + if volunteer_scope == "self": volunteer_experiences = {e.name for e in volunteer_data.experiences} return experience_name in volunteer_experiences - elif volunteer_scope == 'loved_one': + elif volunteer_scope == "loved_one": volunteer_loved_experiences = {e.name for e in volunteer_data.loved_one_experiences} return experience_name in volunteer_loved_experiences From 22897dc58ad01c3f47a114e8e41497cd3a9b12d0 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Wed, 17 Sep 2025 18:38:35 -0400 Subject: [PATCH 4/4] fix ci --- .github/workflows/backend-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 55dfbd29..b4ea80ba 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -66,6 +66,7 @@ jobs: working-directory: ./backend run: | echo "POSTGRES_TEST_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env + echo "POSTGRES_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env echo "SECRET_KEY=test-secret-key-for-ci" >> .env echo "ENVIRONMENT=test" >> .env @@ -163,6 +164,7 @@ jobs: working-directory: ./backend run: | echo "POSTGRES_TEST_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env + echo "POSTGRES_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env echo "SECRET_KEY=test-secret-key-for-ci" >> .env echo "ENVIRONMENT=test" >> .env echo "TEST_SCRIPT_BACKEND_URL=http://localhost:8000" >> .env