Skip to content

Commit 0e2d621

Browse files
richieb21YashK2005
andauthored
Backend to add ranking volunteers, extra volunteer seeds (#84)
## Notion ticket link <!-- Please replace with your ticket's URL --> [Admin Matching Page](https://www.notion.so/uwblueprintexecs/Admin-Dash-Matching-Pages-27210f3fb1dc800aa962c0bb5c09ba15) <!-- Give a quick summary of the implementation details, provide design justifications if necessary --> ## Implementation description * Added a new admin-only endpoint to get all the ranked potential matches for a given participant * Added new service methods to query all volunteers and preload all necessary fields to render <!-- What should the reviewer do to verify your changes? Describe expected results and include screenshots when appropriate --> ## Steps to test 1. pdm run seed to get the new seeded volunteers 2. Go onto admin directory and pick a participant 3. Verify all volunteers are ranked on the table <!-- Draw attention to the substantial parts of your PR or anything you'd like a second opinion on --> ## What should reviewers focus on? * ## Checklist - [ ] My PR name is descriptive and in imperative tense - [ ] My commit messages are descriptive and in imperative tense. My commits are atomic and trivial commits are squashed or fixup'd into non-trivial commits - [ ] I have run the appropriate linter(s) - [ ] I have requested a review from the PL, as well as other devs who have background knowledge on this PR or who will be building on top of this PR --------- Co-authored-by: Yash Kothari <[email protected]>
1 parent e26c631 commit 0e2d621

File tree

7 files changed

+316
-5
lines changed

7 files changed

+316
-5
lines changed

backend/app/routes/auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ async def register_user(user: UserCreateRequest, user_service: UserService = Dep
2424
2525
2626
27+
2728
]
2829
if user.role == UserRole.ADMIN:
2930
if user.email not in allowed_Admins:

backend/app/routes/matching.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
from fastapi import APIRouter, Depends, HTTPException
44
from sqlalchemy.orm import Session
55

6-
from app.schemas.matching import RelevantUsersResponse
6+
from app.middleware.auth import has_roles
7+
from app.schemas.matching import AdminMatchesResponse, RelevantUsersResponse
8+
from app.schemas.user import UserRole
79
from app.services.implementations.matching_service import MatchingService
810
from app.utilities.db_utils import get_db
911

@@ -31,3 +33,25 @@ async def get_matches(user_id: UUID, matching_service: MatchingService = Depends
3133
raise http_ex
3234
except Exception as e:
3335
raise HTTPException(status_code=500, detail=str(e))
36+
37+
38+
@router.get("/admin/{participant_id}", response_model=AdminMatchesResponse)
39+
async def get_admin_matches(
40+
participant_id: UUID,
41+
matching_service: MatchingService = Depends(get_matching_service),
42+
_authorized: bool = has_roles([UserRole.ADMIN]),
43+
):
44+
"""
45+
Get potential volunteer matches for a participant with full volunteer details for admin view.
46+
Returns all volunteers with their complete information (timezone, age, diagnosis, treatments,
47+
experiences) and match scores, sorted by score (highest first).
48+
"""
49+
try:
50+
matched_data = await matching_service.get_admin_matches(participant_id)
51+
return AdminMatchesResponse(matches=matched_data)
52+
except ValueError as ve:
53+
raise HTTPException(status_code=404, detail=str(ve))
54+
except HTTPException as http_ex:
55+
raise http_ex
56+
except Exception as e:
57+
raise HTTPException(status_code=500, detail=str(e))

backend/app/routes/ranking.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from typing import List
2+
from uuid import UUID
23

34
from fastapi import APIRouter, Depends, HTTPException, Query, Request
45
from pydantic import BaseModel, Field
56
from sqlalchemy.orm import Session
67

78
from app.middleware.auth import has_roles
9+
from app.models import Experience, Quality, Treatment, User
10+
from app.models.RankingPreference import RankingPreference
811
from app.schemas.user import UserRole
912
from app.services.implementations.ranking_service import RankingService
1013
from app.utilities.db_utils import get_db
@@ -101,6 +104,67 @@ async def get_participant_case(
101104
raise HTTPException(status_code=500, detail=str(e))
102105

103106

107+
class PreferenceItemWithName(BaseModel):
108+
kind: str
109+
id: int
110+
scope: str
111+
rank: int
112+
name: str
113+
114+
115+
@router.get("/preferences/{user_id}", response_model=List[PreferenceItemWithName])
116+
async def get_ranking_preferences(
117+
user_id: str,
118+
target: str = Query(..., pattern="^(patient|caregiver)$"),
119+
db: Session = Depends(get_db),
120+
authorized: bool = has_roles([UserRole.ADMIN]),
121+
) -> List[PreferenceItemWithName]:
122+
"""Get ranking preferences for a user (admin only)."""
123+
try:
124+
user_uuid = UUID(user_id)
125+
user = db.query(User).filter(User.id == user_uuid).first()
126+
if not user:
127+
raise HTTPException(status_code=404, detail="User not found")
128+
129+
preferences = (
130+
db.query(RankingPreference)
131+
.filter(RankingPreference.user_id == user_uuid, RankingPreference.target_role == target)
132+
.order_by(RankingPreference.rank)
133+
.all()
134+
)
135+
136+
result = []
137+
for pref in preferences:
138+
item_id = None
139+
name = None
140+
if pref.kind == "quality" and pref.quality_id:
141+
item_id = pref.quality_id
142+
quality = db.query(Quality).filter(Quality.id == pref.quality_id).first()
143+
if quality:
144+
name = quality.label
145+
elif pref.kind == "treatment" and pref.treatment_id:
146+
item_id = pref.treatment_id
147+
treatment = db.query(Treatment).filter(Treatment.id == pref.treatment_id).first()
148+
if treatment:
149+
name = treatment.name
150+
elif pref.kind == "experience" and pref.experience_id:
151+
item_id = pref.experience_id
152+
experience = db.query(Experience).filter(Experience.id == pref.experience_id).first()
153+
if experience:
154+
name = experience.name
155+
156+
if item_id is not None and name:
157+
result.append(
158+
PreferenceItemWithName(kind=pref.kind, id=item_id, scope=pref.scope, rank=pref.rank, name=name)
159+
)
160+
161+
return result
162+
except HTTPException:
163+
raise
164+
except Exception as e:
165+
raise HTTPException(status_code=500, detail=str(e))
166+
167+
104168
@router.get("/admin/options", response_model=RankingOptionsResponse)
105169
async def get_ranking_options_admin(
106170
user_id: str = Query(..., description="User ID (UUID) to fetch options for"),

backend/app/schemas/matching.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
Pydantic schemas for matching-related data validation and serialization.
33
"""
44

5-
from typing import List
5+
from typing import List, Optional
6+
from uuid import UUID
67

78
from pydantic import BaseModel
89

@@ -24,3 +25,29 @@ class RelevantUsersResponse(BaseModel):
2425
"""
2526

2627
matches: List[MatchedUser]
28+
29+
30+
class AdminMatchCandidate(BaseModel):
31+
"""
32+
Schema for an admin match candidate with full volunteer details and match score.
33+
Used for displaying potential matches in the admin interface.
34+
"""
35+
36+
volunteer_id: UUID
37+
first_name: Optional[str]
38+
last_name: Optional[str]
39+
email: str
40+
timezone: Optional[str]
41+
age: Optional[int]
42+
diagnosis: Optional[str]
43+
treatments: List[str] = []
44+
experiences: List[str] = []
45+
match_score: float # 0-100 scale
46+
47+
48+
class AdminMatchesResponse(BaseModel):
49+
"""
50+
Response schema for admin matching endpoint containing a list of match candidates with full details.
51+
"""
52+
53+
matches: List[AdminMatchCandidate]

backend/app/seeds/users.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,111 @@ def seed_users(session: Session) -> None:
344344
"loved_one_treatments": [3, 6], # Chemotherapy, Radiation
345345
"loved_one_experiences": [3, 4], # Feeling Overwhelmed, Fatigue
346346
},
347+
# Additional volunteers for testing
348+
{
349+
"role": "volunteer",
350+
"user_data": {
351+
"first_name": "James",
352+
"last_name": "Wilson",
353+
"email": "[email protected]",
354+
"auth_id": "auth_james_011",
355+
"date_of_birth": date(1990, 3, 20),
356+
"phone": "555-0402",
357+
"city": "Calgary",
358+
"province": "Alberta",
359+
"postal_code": "T2P 1J4",
360+
"gender_identity": "Man",
361+
"pronouns": ["he", "him"],
362+
"ethnic_group": ["White/Caucasian"],
363+
"marital_status": "Single",
364+
"has_kids": "No",
365+
"timezone": "MST",
366+
"diagnosis": "Non-Hodgkin Lymphoma",
367+
"date_of_diagnosis": date(2019, 6, 10),
368+
"has_blood_cancer": "yes",
369+
"caring_for_someone": "no",
370+
},
371+
"treatments": [3, 6, 14], # Chemotherapy, Radiation, BTK Inhibitors
372+
"experiences": [1, 3, 4, 7], # Brain Fog, Feeling Overwhelmed, Fatigue, Returning to work
373+
},
374+
{
375+
"role": "volunteer",
376+
"user_data": {
377+
"first_name": "Maria",
378+
"last_name": "Garcia",
379+
"email": "[email protected]",
380+
"auth_id": "auth_maria_012",
381+
"date_of_birth": date(1988, 8, 15),
382+
"phone": "555-0403",
383+
"city": "Vancouver",
384+
"province": "British Columbia",
385+
"postal_code": "V6B 2K1",
386+
"gender_identity": "Woman",
387+
"pronouns": ["she", "her"],
388+
"ethnic_group": ["Hispanic/Latino"],
389+
"marital_status": "Married/Common Law",
390+
"has_kids": "Yes",
391+
"timezone": "PST",
392+
"diagnosis": "Acute Myeloid Leukemia",
393+
"date_of_diagnosis": date(2021, 1, 8),
394+
"has_blood_cancer": "yes",
395+
"caring_for_someone": "no",
396+
},
397+
"treatments": [3, 10], # Chemotherapy, Autologous Stem Cell Transplant
398+
"experiences": [3, 4, 10, 11], # Feeling Overwhelmed, Fatigue, Anxiety/Depression, PTSD
399+
},
400+
{
401+
"role": "volunteer",
402+
"user_data": {
403+
"first_name": "Alex",
404+
"last_name": "Martinez",
405+
"email": "[email protected]",
406+
"auth_id": "auth_alex_013",
407+
"date_of_birth": date(1992, 11, 5),
408+
"phone": "555-0404",
409+
"city": "Toronto",
410+
"province": "Ontario",
411+
"postal_code": "M5H 2N2",
412+
"gender_identity": "Non-binary",
413+
"pronouns": ["they", "them"],
414+
"ethnic_group": ["Hispanic/Latino"],
415+
"marital_status": "Single",
416+
"has_kids": "No",
417+
"timezone": "EST",
418+
"diagnosis": "Chronic Myeloid Leukemia",
419+
"date_of_diagnosis": date(2020, 9, 12),
420+
"has_blood_cancer": "yes",
421+
"caring_for_someone": "no",
422+
},
423+
"treatments": [14, 15], # BTK Inhibitors, Targeted Therapy
424+
"experiences": [1, 10], # Brain Fog, Anxiety/Depression
425+
},
426+
{
427+
"role": "volunteer",
428+
"user_data": {
429+
"first_name": "Patricia",
430+
"last_name": "Brown",
431+
"email": "[email protected]",
432+
"auth_id": "auth_patricia_014",
433+
"date_of_birth": date(1985, 4, 18),
434+
"phone": "555-0405",
435+
"city": "Montreal",
436+
"province": "Quebec",
437+
"postal_code": "H3B 1M8",
438+
"gender_identity": "Woman",
439+
"pronouns": ["she", "her"],
440+
"ethnic_group": ["Black/African"],
441+
"marital_status": "Married/Common Law",
442+
"has_kids": "Yes",
443+
"timezone": "EST",
444+
"diagnosis": "Multiple Myeloma",
445+
"date_of_diagnosis": date(2019, 11, 22),
446+
"has_blood_cancer": "yes",
447+
"caring_for_someone": "no",
448+
},
449+
"treatments": [3, 10, 11], # Chemotherapy, Autologous Stem Cell Transplant, Allogeneic Stem Cell Transplant
450+
"experiences": [1, 3, 4, 5, 7], # Brain Fog, Feeling Overwhelmed, Fatigue, Sleep Issues, Returning to work
451+
},
347452
]
348453

349454
created_users = []

backend/app/services/implementations/matching_service.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from uuid import UUID
66

77
from fastapi import HTTPException
8-
from sqlalchemy.orm import Session
8+
from sqlalchemy.orm import Session, joinedload
99

1010
from app.interfaces.matching_service import IMatchingService
1111
from app.models.Experience import Experience
@@ -99,6 +99,96 @@ async def get_matches(self, participant_id: UUID, limit: Optional[int] = 5) -> L
9999
self.logger.error(f"Error finding matches: {str(e)}")
100100
raise HTTPException(status_code=500, detail=f"Internal server error during matching process: {str(e)}")
101101

102+
async def get_admin_matches(self, participant_id: UUID) -> List[Dict[str, Any]]:
103+
"""
104+
Get potential volunteer matches for a participant with full volunteer details for admin view.
105+
Returns all volunteers with their complete information and match scores.
106+
:param participant_id: ID of the participant user to find matches for
107+
:return: List of dictionaries with full volunteer details and match scores
108+
:raises ValueError: If user is not found or not a participant
109+
"""
110+
try:
111+
# Get the participant user
112+
user = self.db.query(User).filter(User.id == participant_id).first()
113+
if not user:
114+
raise ValueError(f"User with ID {participant_id} not found")
115+
116+
if user.role.name != UserRole.PARTICIPANT:
117+
raise ValueError(f"User with ID {participant_id} is not a participant")
118+
119+
participant_data = self.db.query(UserData).filter(UserData.user_id == participant_id).first()
120+
if not participant_data:
121+
raise ValueError(f"User with ID {participant_id} has no intake form data")
122+
123+
participant_preferences = self._get_user_preferences(participant_id)
124+
if not participant_preferences:
125+
raise ValueError(f"User with ID {participant_id} has no ranking form data")
126+
127+
# Get all active, approved volunteers with their data and relationships
128+
# Eagerly load user_data, treatments, and experiences to avoid N+1 queries
129+
volunteers = (
130+
self.db.query(User)
131+
.join(User.role)
132+
.options(
133+
joinedload(User.user_data).joinedload(UserData.treatments),
134+
joinedload(User.user_data).joinedload(UserData.experiences),
135+
)
136+
.filter(Role.name == UserRole.VOLUNTEER)
137+
.filter(User.active)
138+
.filter(User.approved)
139+
.all()
140+
)
141+
142+
if not volunteers:
143+
return []
144+
145+
# Calculate scores and build detailed responses
146+
scored_volunteers = []
147+
for volunteer_user in volunteers:
148+
volunteer_data = volunteer_user.user_data
149+
if not volunteer_data:
150+
continue
151+
score = self._calculate_match_score(participant_data, volunteer_data, participant_preferences)
152+
153+
# Calculate age from date_of_birth
154+
age = None
155+
if volunteer_data.date_of_birth:
156+
today = date.today()
157+
age = today.year - volunteer_data.date_of_birth.year
158+
# Adjust if birthday hasn't occurred this year
159+
if (today.month, today.day) < (
160+
volunteer_data.date_of_birth.month,
161+
volunteer_data.date_of_birth.day,
162+
):
163+
age -= 1
164+
165+
treatment_names = [treatment.name for treatment in volunteer_data.treatments]
166+
167+
experience_names = [experience.name for experience in volunteer_data.experiences]
168+
169+
match_candidate = {
170+
"volunteer_id": volunteer_user.id,
171+
"first_name": volunteer_user.first_name,
172+
"last_name": volunteer_user.last_name,
173+
"email": volunteer_user.email,
174+
"timezone": volunteer_data.timezone,
175+
"age": age,
176+
"diagnosis": volunteer_data.diagnosis,
177+
"treatments": treatment_names,
178+
"experiences": experience_names,
179+
"match_score": round(score * 100, 2),
180+
}
181+
scored_volunteers.append((match_candidate, score))
182+
183+
scored_volunteers.sort(key=lambda x: x[1], reverse=True)
184+
return [candidate for candidate, _ in scored_volunteers]
185+
186+
except ValueError as ve:
187+
raise ve
188+
except Exception as e:
189+
self.logger.error(f"Error finding admin matches: {str(e)}")
190+
raise HTTPException(status_code=500, detail=f"Internal server error during matching process: {str(e)}")
191+
102192
def _get_user_preferences(self, user_id: UUID) -> List[Dict[str, Any]]:
103193
"""Get user's ranking preferences with full context."""
104194
preferences = (

backend/app/services/implementations/user_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ async def create_user(self, user: UserCreateRequest) -> UserCreateResponse:
6060

6161
# Create user in database
6262
db_user = User(
63-
first_name=user.first_name or "",
64-
last_name=user.last_name or "",
63+
first_name=user.first_name if user.first_name else None,
64+
last_name=user.last_name if user.last_name else None,
6565
email=user.email,
6666
role_id=role_id,
6767
auth_id=firebase_user.uid,

0 commit comments

Comments
 (0)