Skip to content

Commit 64d75c8

Browse files
committed
Backend to add ranking volunteers, extra volunteer seeds
1 parent 84470d0 commit 64d75c8

File tree

7 files changed

+320
-5
lines changed

7 files changed

+320
-5
lines changed

backend/app/routes/auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ async def register_user(user: UserCreateRequest, user_service: UserService = Dep
2222
2323
2424
25+
2526
]
2627
if user.role == UserRole.ADMIN:
2728
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: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
from app.services.implementations.ranking_service import RankingService
1010
from app.utilities.db_utils import get_db
1111

12+
from uuid import UUID
13+
from app.models import User
14+
from app.models.RankingPreference import RankingPreference
15+
from app.models import Quality, Treatment, Experience
16+
1217

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

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
@@ -304,6 +304,111 @@ def seed_users(session: Session) -> None:
304304
"loved_one_treatments": [3, 6], # Chemotherapy, Radiation
305305
"loved_one_experiences": [3, 4], # Feeling Overwhelmed, Fatigue
306306
},
307+
# Additional volunteers for testing
308+
{
309+
"role": "volunteer",
310+
"user_data": {
311+
"first_name": "James",
312+
"last_name": "Wilson",
313+
"email": "[email protected]",
314+
"auth_id": "auth_james_011",
315+
"date_of_birth": date(1990, 3, 20),
316+
"phone": "555-0402",
317+
"city": "Calgary",
318+
"province": "Alberta",
319+
"postal_code": "T2P 1J4",
320+
"gender_identity": "Man",
321+
"pronouns": ["he", "him"],
322+
"ethnic_group": ["White/Caucasian"],
323+
"marital_status": "Single",
324+
"has_kids": "No",
325+
"timezone": "MST",
326+
"diagnosis": "Non-Hodgkin Lymphoma",
327+
"date_of_diagnosis": date(2019, 6, 10),
328+
"has_blood_cancer": "yes",
329+
"caring_for_someone": "no",
330+
},
331+
"treatments": [3, 6, 14], # Chemotherapy, Radiation, BTK Inhibitors
332+
"experiences": [1, 3, 4, 7], # Brain Fog, Feeling Overwhelmed, Fatigue, Returning to work
333+
},
334+
{
335+
"role": "volunteer",
336+
"user_data": {
337+
"first_name": "Maria",
338+
"last_name": "Garcia",
339+
"email": "[email protected]",
340+
"auth_id": "auth_maria_012",
341+
"date_of_birth": date(1988, 8, 15),
342+
"phone": "555-0403",
343+
"city": "Vancouver",
344+
"province": "British Columbia",
345+
"postal_code": "V6B 2K1",
346+
"gender_identity": "Woman",
347+
"pronouns": ["she", "her"],
348+
"ethnic_group": ["Hispanic/Latino"],
349+
"marital_status": "Married/Common Law",
350+
"has_kids": "Yes",
351+
"timezone": "PST",
352+
"diagnosis": "Acute Myeloid Leukemia",
353+
"date_of_diagnosis": date(2021, 1, 8),
354+
"has_blood_cancer": "yes",
355+
"caring_for_someone": "no",
356+
},
357+
"treatments": [3, 10], # Chemotherapy, Autologous Stem Cell Transplant
358+
"experiences": [3, 4, 10, 11], # Feeling Overwhelmed, Fatigue, Anxiety/Depression, PTSD
359+
},
360+
{
361+
"role": "volunteer",
362+
"user_data": {
363+
"first_name": "Alex",
364+
"last_name": "Martinez",
365+
"email": "[email protected]",
366+
"auth_id": "auth_alex_013",
367+
"date_of_birth": date(1992, 11, 5),
368+
"phone": "555-0404",
369+
"city": "Toronto",
370+
"province": "Ontario",
371+
"postal_code": "M5H 2N2",
372+
"gender_identity": "Non-binary",
373+
"pronouns": ["they", "them"],
374+
"ethnic_group": ["Hispanic/Latino"],
375+
"marital_status": "Single",
376+
"has_kids": "No",
377+
"timezone": "EST",
378+
"diagnosis": "Chronic Myeloid Leukemia",
379+
"date_of_diagnosis": date(2020, 9, 12),
380+
"has_blood_cancer": "yes",
381+
"caring_for_someone": "no",
382+
},
383+
"treatments": [14, 15], # BTK Inhibitors, Targeted Therapy
384+
"experiences": [1, 10], # Brain Fog, Anxiety/Depression
385+
},
386+
{
387+
"role": "volunteer",
388+
"user_data": {
389+
"first_name": "Patricia",
390+
"last_name": "Brown",
391+
"email": "[email protected]",
392+
"auth_id": "auth_patricia_014",
393+
"date_of_birth": date(1985, 4, 18),
394+
"phone": "555-0405",
395+
"city": "Montreal",
396+
"province": "Quebec",
397+
"postal_code": "H3B 1M8",
398+
"gender_identity": "Woman",
399+
"pronouns": ["she", "her"],
400+
"ethnic_group": ["Black/African"],
401+
"marital_status": "Married/Common Law",
402+
"has_kids": "Yes",
403+
"timezone": "EST",
404+
"diagnosis": "Multiple Myeloma",
405+
"date_of_diagnosis": date(2019, 11, 22),
406+
"has_blood_cancer": "yes",
407+
"caring_for_someone": "no",
408+
},
409+
"treatments": [3, 10, 11], # Chemotherapy, Autologous Stem Cell Transplant, Allogeneic Stem Cell Transplant
410+
"experiences": [1, 3, 4, 5, 7], # Brain Fog, Feeling Overwhelmed, Fatigue, Sleep Issues, Returning to work
411+
},
307412
]
308413

309414
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, contains_eager, 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)