Skip to content

Commit 0b49e14

Browse files
matia-leeYashK2005
andauthored
ranking forms (#47)
## Notion ticket link [llsc-74-ranking forms](https://www.notion.so/uwblueprintexecs/Ranking-Forms-20510f3fb1dc80fd8cfacfd8af1ce2df?source=copy_link) <!-- Give a quick summary of the implementation details, provide design justifications if necessary --> ## Implementation description This PR completes the ranking forms on both the frontend and backend. It also includes a migration to update the ranking_preferences schema so that we can store experiences/treatments rather than just qualities, and so that we can also keep track of whether the scope of the preference selected is for the volunteer or the volunteer's loved one (which is needed for cases where the user is a caregiver requesting a caregiver volunteer). This should hopefully make getting the ranking preferences easier for the matching part. A bunch of frontend improvements as well. The main one is the new protected page component which allows us to restrict certain types of users from viewing certain pages (so that volunteers can't access the participant intake or ranking form and participants can't access the volunteer intake form, etc.). To complete the forms, we still need to do the secondary application form for volunteers. I think the backend has been started in another branch but I don't think we've started the frontend yet. We also need the logic for controlling what should be shown to users when they login for both participants and volunteers (eg. should it be the intake form, intake thank you note, ranking form, ranking thank you note, or dashboard). This can probably go in a separate PR later? We'll probably need some new endpoints for allowing admins to move users between stages and potentially schema changes (haven't thought about this in much detail yet). I think some minor styling improvements can be made to better match the figma designs, as well as making sure our forms look good on mobile as well. <!-- What should the reviewer do to verify your changes? Describe expected results and include screenshots when appropriate --> ## Steps to test 1. <!-- 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: YashK2005 <[email protected]>
1 parent eaca682 commit 0b49e14

36 files changed

+3177
-197
lines changed

.github/workflows/backend-ci.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
test:
1717
runs-on: ubuntu-latest
1818
env:
19-
POSTGRES_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test
19+
POSTGRES_TEST_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test
2020

2121
strategy:
2222
matrix:
@@ -65,13 +65,14 @@ jobs:
6565
- name: Set up environment variables
6666
working-directory: ./backend
6767
run: |
68-
echo "POSTGRES_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env
68+
echo "POSTGRES_TEST_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env
6969
echo "SECRET_KEY=test-secret-key-for-ci" >> .env
7070
echo "ENVIRONMENT=test" >> .env
7171
7272
- name: Run database migrations
7373
working-directory: ./backend
7474
run: |
75+
export POSTGRES_DATABASE_URL="$POSTGRES_TEST_DATABASE_URL"
7576
pdm run alembic upgrade heads
7677
7778
- name: Run linting
@@ -118,7 +119,7 @@ jobs:
118119
runs-on: ubuntu-latest
119120
needs: test
120121
env:
121-
POSTGRES_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test
122+
POSTGRES_TEST_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test
122123

123124
services:
124125
postgres:
@@ -155,7 +156,7 @@ jobs:
155156
- name: Set up environment variables
156157
working-directory: ./backend
157158
run: |
158-
echo "POSTGRES_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env
159+
echo "POSTGRES_TEST_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env
159160
echo "SECRET_KEY=test-secret-key-for-ci" >> .env
160161
echo "ENVIRONMENT=test" >> .env
161162
echo "TEST_SCRIPT_BACKEND_URL=http://localhost:8000" >> .env
@@ -165,6 +166,7 @@ jobs:
165166
- name: Run database migrations
166167
working-directory: ./backend
167168
run: |
169+
export POSTGRES_DATABASE_URL="$POSTGRES_TEST_DATABASE_URL"
168170
pdm run alembic upgrade heads
169171
170172
- name: Start backend server
Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from sqlalchemy import Column, ForeignKey, Integer
1+
from sqlalchemy import CheckConstraint, Column, Enum, ForeignKey, Integer
22
from sqlalchemy.dialects.postgresql import UUID
33
from sqlalchemy.orm import relationship
44

@@ -9,9 +9,38 @@ class RankingPreference(Base):
99
__tablename__ = "ranking_preferences"
1010

1111
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), primary_key=True)
12-
quality_id = Column(Integer, ForeignKey("qualities.id"), primary_key=True)
13-
rank = Column(Integer, nullable=False) # 1 = most important
12+
# patient or caregiver (the counterpart the participant is ranking for)
13+
target_role = Column(Enum("patient", "caregiver", name="target_role"), primary_key=True)
1414

15-
# Relationships
15+
# kind of item: quality, treatment, or experience
16+
kind = Column(Enum("quality", "treatment", "experience", name="ranking_kind"))
17+
18+
# one of these will be set based on kind
19+
quality_id = Column(Integer, nullable=True)
20+
treatment_id = Column(Integer, nullable=True)
21+
experience_id = Column(Integer, nullable=True)
22+
23+
# scope: self or loved_one; always required (including qualities)
24+
scope = Column(Enum("self", "loved_one", name="ranking_scope"), nullable=False)
25+
26+
# rank: 1 is highest
27+
rank = Column(Integer, nullable=False, primary_key=True)
28+
29+
# relationships
1630
user = relationship("User")
17-
quality = relationship("Quality")
31+
32+
__table_args__ = (
33+
# enforce exclusive columns by kind
34+
CheckConstraint(
35+
"(kind <> 'quality') OR (quality_id IS NOT NULL AND treatment_id IS NULL AND experience_id IS NULL)",
36+
name="ck_ranking_pref_quality_fields",
37+
),
38+
CheckConstraint(
39+
"(kind <> 'treatment') OR (treatment_id IS NOT NULL AND quality_id IS NULL AND experience_id IS NULL)",
40+
name="ck_ranking_pref_treatment_fields",
41+
),
42+
CheckConstraint(
43+
"(kind <> 'experience') OR (experience_id IS NOT NULL AND quality_id IS NULL AND treatment_id IS NULL)",
44+
name="ck_ranking_pref_experience_fields",
45+
),
46+
)

backend/app/routes/__init__.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1-
from . import auth, availability, intake, match, send_email, suggested_times, test, user
1+
from . import auth, availability, intake, match, ranking, send_email, suggested_times, test, user
22

3-
__all__ = ["auth", "availability", "intake", "match", "send_email", "suggested_times", "test", "user"]
3+
__all__ = [
4+
"auth",
5+
"availability",
6+
"intake",
7+
"match",
8+
"ranking",
9+
"send_email",
10+
"suggested_times",
11+
"test",
12+
"user",
13+
]

backend/app/routes/auth.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from fastapi import APIRouter, Depends, HTTPException, Request, Response
22
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
3+
from sqlalchemy.orm import Session
34

5+
from ..models.User import User
46
from ..schemas.auth import AuthResponse, LoginRequest, RefreshRequest, Token
57
from ..schemas.user import UserCreateRequest, UserCreateResponse, UserRole
68
from ..services.implementations.auth_service import AuthService
79
from ..services.implementations.user_service import UserService
10+
from ..utilities.db_utils import get_db
811
from ..utilities.service_utils import get_auth_service, get_user_service
912

1013
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -96,3 +99,37 @@ async def verify_email(email: str, auth_service: AuthService = Depends(get_auth_
9699
# Log unexpected errors
97100
print(f"Unexpected error during email verification for {email}: {str(e)}")
98101
return Response(status_code=500)
102+
103+
104+
@router.get("/me", response_model=UserCreateResponse)
105+
async def get_current_user(
106+
request: Request,
107+
credentials: HTTPAuthorizationCredentials = Depends(security),
108+
db: Session = Depends(get_db),
109+
):
110+
"""Get current authenticated user information including role"""
111+
try:
112+
# Get user auth_id from request state (set by auth middleware)
113+
user_auth_id = request.state.user_id
114+
if not user_auth_id:
115+
raise HTTPException(status_code=401, detail="Authentication required")
116+
117+
# Query user from database
118+
user = db.query(User).filter(User.auth_id == user_auth_id).first()
119+
if not user:
120+
raise HTTPException(status_code=404, detail="User not found")
121+
122+
return UserCreateResponse(
123+
id=user.id,
124+
first_name=user.first_name,
125+
last_name=user.last_name,
126+
email=user.email,
127+
role_id=user.role_id,
128+
auth_id=user.auth_id,
129+
approved=user.approved,
130+
)
131+
except HTTPException:
132+
raise
133+
except Exception as e:
134+
print(f"Error getting current user: {str(e)}")
135+
raise HTTPException(status_code=500, detail="Internal server error")

backend/app/routes/ranking.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from typing import List
2+
3+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
4+
from pydantic import BaseModel, Field
5+
from sqlalchemy.orm import Session
6+
7+
from app.middleware.auth import has_roles
8+
from app.schemas.user import UserRole
9+
from app.services.implementations.ranking_service import RankingService
10+
from app.utilities.db_utils import get_db
11+
12+
13+
class StaticQualityOption(BaseModel):
14+
quality_id: int
15+
slug: str
16+
label: str
17+
allowed_scopes: List[str] | None = Field(default=None, description="Optional whitelisted scopes for this quality")
18+
19+
20+
class DynamicOption(BaseModel):
21+
kind: str # 'treatment' | 'experience'
22+
id: int
23+
name: str
24+
scope: str # 'self' | 'loved_one'
25+
26+
27+
class RankingOptionsResponse(BaseModel):
28+
static_qualities: List[StaticQualityOption]
29+
dynamic_options: List[DynamicOption]
30+
31+
32+
router = APIRouter(prefix="/ranking", tags=["ranking"])
33+
34+
35+
@router.get("/options", response_model=RankingOptionsResponse)
36+
async def get_ranking_options(
37+
request: Request,
38+
target: str = Query(..., pattern="^(patient|caregiver)$"),
39+
db: Session = Depends(get_db),
40+
authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
41+
) -> RankingOptionsResponse:
42+
try:
43+
service = RankingService(db)
44+
user_auth_id = request.state.user_id
45+
options = service.get_options(user_auth_id=user_auth_id, target=target)
46+
return RankingOptionsResponse(**options)
47+
except HTTPException:
48+
raise
49+
except Exception as e:
50+
raise HTTPException(status_code=500, detail=str(e))
51+
52+
53+
class PreferenceItem(BaseModel):
54+
kind: str # 'quality' | 'treatment' | 'experience'
55+
id: int
56+
scope: str # 'self' | 'loved_one'
57+
rank: int
58+
59+
60+
@router.put("/preferences", status_code=204)
61+
async def put_ranking_preferences(
62+
request: Request,
63+
target: str = Query(..., pattern="^(patient|caregiver)$"),
64+
items: List[PreferenceItem] = [],
65+
db: Session = Depends(get_db),
66+
authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
67+
) -> None:
68+
try:
69+
service = RankingService(db)
70+
user_auth_id = request.state.user_id
71+
# Convert Pydantic models to dicts
72+
payload = [i.model_dump() for i in items]
73+
service.save_preferences(user_auth_id=user_auth_id, target=target, items=payload)
74+
return None
75+
except HTTPException:
76+
raise
77+
except Exception as e:
78+
raise HTTPException(status_code=500, detail=str(e))
79+
80+
81+
class CaseResponse(BaseModel):
82+
case: str
83+
has_blood_cancer: str | None = None
84+
caring_for_someone: str | None = None
85+
86+
87+
@router.get("/case", response_model=CaseResponse)
88+
async def get_participant_case(
89+
request: Request,
90+
db: Session = Depends(get_db),
91+
authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
92+
) -> CaseResponse:
93+
try:
94+
service = RankingService(db)
95+
user_auth_id = request.state.user_id
96+
result = service.get_case(user_auth_id)
97+
return CaseResponse(**result)
98+
except HTTPException:
99+
raise
100+
except Exception as e:
101+
raise HTTPException(status_code=500, detail=str(e))

backend/app/server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from . import models
1010
from .middleware.auth_middleware import AuthMiddleware
11-
from .routes import auth, availability, intake, match, send_email, suggested_times, test, user
11+
from .routes import auth, availability, intake, match, ranking, send_email, suggested_times, test, user
1212
from .utilities.constants import LOGGER_NAME
1313
from .utilities.firebase_init import initialize_firebase
1414
from .utilities.ses.ses_init import ensure_ses_templates
@@ -68,6 +68,7 @@ async def lifespan(_: FastAPI):
6868
app.include_router(suggested_times.router)
6969
app.include_router(match.router)
7070
app.include_router(intake.router)
71+
app.include_router(ranking.router)
7172
app.include_router(send_email.router)
7273
app.include_router(test.router)
7374

0 commit comments

Comments
 (0)