Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
test:
runs-on: ubuntu-latest
env:
POSTGRES_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test
POSTGRES_TEST_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test

strategy:
matrix:
Expand Down Expand Up @@ -65,13 +65,14 @@ jobs:
- name: Set up environment variables
working-directory: ./backend
run: |
echo "POSTGRES_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env
echo "POSTGRES_TEST_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env
echo "SECRET_KEY=test-secret-key-for-ci" >> .env
echo "ENVIRONMENT=test" >> .env

- name: Run database migrations
working-directory: ./backend
run: |
export POSTGRES_DATABASE_URL="$POSTGRES_TEST_DATABASE_URL"
pdm run alembic upgrade heads

- name: Run linting
Expand Down Expand Up @@ -118,7 +119,7 @@ jobs:
runs-on: ubuntu-latest
needs: test
env:
POSTGRES_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test
POSTGRES_TEST_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test

services:
postgres:
Expand Down Expand Up @@ -155,7 +156,7 @@ jobs:
- name: Set up environment variables
working-directory: ./backend
run: |
echo "POSTGRES_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env
echo "POSTGRES_TEST_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
Expand All @@ -165,6 +166,7 @@ jobs:
- name: Run database migrations
working-directory: ./backend
run: |
export POSTGRES_DATABASE_URL="$POSTGRES_TEST_DATABASE_URL"
pdm run alembic upgrade heads

- name: Start backend server
Expand Down
39 changes: 34 additions & 5 deletions backend/app/models/RankingPreference.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from sqlalchemy import Column, ForeignKey, Integer
from sqlalchemy import CheckConstraint, Column, Enum, ForeignKey, Integer
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship

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

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

# Relationships
# kind of item: quality, treatment, or experience
kind = Column(Enum("quality", "treatment", "experience", name="ranking_kind"))

# one of these will be set based on kind
quality_id = Column(Integer, nullable=True)
treatment_id = Column(Integer, nullable=True)
experience_id = Column(Integer, nullable=True)

# scope: self or loved_one; always required (including qualities)
scope = Column(Enum("self", "loved_one", name="ranking_scope"), nullable=False)

# rank: 1 is highest
rank = Column(Integer, nullable=False, primary_key=True)

# relationships
user = relationship("User")
quality = relationship("Quality")

__table_args__ = (
# enforce exclusive columns by kind
CheckConstraint(
"(kind <> 'quality') OR (quality_id IS NOT NULL AND treatment_id IS NULL AND experience_id IS NULL)",
name="ck_ranking_pref_quality_fields",
),
CheckConstraint(
"(kind <> 'treatment') OR (treatment_id IS NOT NULL AND quality_id IS NULL AND experience_id IS NULL)",
name="ck_ranking_pref_treatment_fields",
),
CheckConstraint(
"(kind <> 'experience') OR (experience_id IS NOT NULL AND quality_id IS NULL AND treatment_id IS NULL)",
name="ck_ranking_pref_experience_fields",
),
)
14 changes: 12 additions & 2 deletions backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
from . import auth, availability, intake, match, send_email, suggested_times, test, user
from . import auth, availability, intake, match, ranking, send_email, suggested_times, test, user

__all__ = ["auth", "availability", "intake", "match", "send_email", "suggested_times", "test", "user"]
__all__ = [
"auth",
"availability",
"intake",
"match",
"ranking",
"send_email",
"suggested_times",
"test",
"user",
]
37 changes: 37 additions & 0 deletions backend/app/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session

from ..models.User import User
from ..schemas.auth import AuthResponse, LoginRequest, RefreshRequest, Token
from ..schemas.user import UserCreateRequest, UserCreateResponse, UserRole
from ..services.implementations.auth_service import AuthService
from ..services.implementations.user_service import UserService
from ..utilities.db_utils import get_db
from ..utilities.service_utils import get_auth_service, get_user_service

router = APIRouter(prefix="/auth", tags=["auth"])
Expand Down Expand Up @@ -96,3 +99,37 @@ async def verify_email(email: str, auth_service: AuthService = Depends(get_auth_
# Log unexpected errors
print(f"Unexpected error during email verification for {email}: {str(e)}")
return Response(status_code=500)


@router.get("/me", response_model=UserCreateResponse)
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
):
"""Get current authenticated user information including role"""
try:
# Get user auth_id from request state (set by auth middleware)
user_auth_id = request.state.user_id
if not user_auth_id:
raise HTTPException(status_code=401, detail="Authentication required")

# Query user from database
user = db.query(User).filter(User.auth_id == user_auth_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")

return UserCreateResponse(
id=user.id,
first_name=user.first_name,
last_name=user.last_name,
email=user.email,
role_id=user.role_id,
auth_id=user.auth_id,
approved=user.approved,
)
except HTTPException:
raise
except Exception as e:
print(f"Error getting current user: {str(e)}")
raise HTTPException(status_code=500, detail="Internal server error")
101 changes: 101 additions & 0 deletions backend/app/routes/ranking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from typing import List

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

from app.middleware.auth import has_roles
from app.schemas.user import UserRole
from app.services.implementations.ranking_service import RankingService
from app.utilities.db_utils import get_db


class StaticQualityOption(BaseModel):
quality_id: int
slug: str
label: str
allowed_scopes: List[str] | None = Field(default=None, description="Optional whitelisted scopes for this quality")


class DynamicOption(BaseModel):
kind: str # 'treatment' | 'experience'
id: int
name: str
scope: str # 'self' | 'loved_one'


class RankingOptionsResponse(BaseModel):
static_qualities: List[StaticQualityOption]
dynamic_options: List[DynamicOption]


router = APIRouter(prefix="/ranking", tags=["ranking"])


@router.get("/options", response_model=RankingOptionsResponse)
async def get_ranking_options(
request: Request,
target: str = Query(..., pattern="^(patient|caregiver)$"),
db: Session = Depends(get_db),
authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
) -> RankingOptionsResponse:
try:
service = RankingService(db)
user_auth_id = request.state.user_id
options = service.get_options(user_auth_id=user_auth_id, target=target)
return RankingOptionsResponse(**options)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


class PreferenceItem(BaseModel):
kind: str # 'quality' | 'treatment' | 'experience'
id: int
scope: str # 'self' | 'loved_one'
rank: int


@router.put("/preferences", status_code=204)
async def put_ranking_preferences(
request: Request,
target: str = Query(..., pattern="^(patient|caregiver)$"),
items: List[PreferenceItem] = [],
db: Session = Depends(get_db),
authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
) -> None:
try:
service = RankingService(db)
user_auth_id = request.state.user_id
# Convert Pydantic models to dicts
payload = [i.model_dump() for i in items]
service.save_preferences(user_auth_id=user_auth_id, target=target, items=payload)
return None
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


class CaseResponse(BaseModel):
case: str
has_blood_cancer: str | None = None
caring_for_someone: str | None = None


@router.get("/case", response_model=CaseResponse)
async def get_participant_case(
request: Request,
db: Session = Depends(get_db),
authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
) -> CaseResponse:
try:
service = RankingService(db)
user_auth_id = request.state.user_id
result = service.get_case(user_auth_id)
return CaseResponse(**result)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
3 changes: 2 additions & 1 deletion backend/app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from . import models
from .middleware.auth_middleware import AuthMiddleware
from .routes import auth, availability, intake, match, send_email, suggested_times, test, user
from .routes import auth, availability, intake, match, 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
Expand Down Expand Up @@ -68,6 +68,7 @@ async def lifespan(_: FastAPI):
app.include_router(suggested_times.router)
app.include_router(match.router)
app.include_router(intake.router)
app.include_router(ranking.router)
app.include_router(send_email.router)
app.include_router(test.router)

Expand Down
Loading