Skip to content
Draft
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@
**/*.cache
**/*.egg-info
**/test.db
.cursor/
.cursor/
.claude/
.codex/
.mcp.json
3 changes: 3 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ local_settings.py
db.sqlite3
db.sqlite3-journal

# Test databases
*.db

# Flask stuff:
instance/
.webassets-cache
Expand Down
12 changes: 12 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,18 @@ To apply the migration, run the following command:
pdm run alembic upgrade head
```

## Testing

### First Time Setup
```bash
pdm run test-setup # Creates test database, runs migrations, seeds data
```

### Run Tests
```bash
pdm run tests
```

### Logging

To add a logger to a new service or file, use the `LOGGER_NAME` function in `app/utilities/constants.py`
Expand Down
1 change: 1 addition & 0 deletions backend/app/models/Match.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class Match(Base):

created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
deleted_at = Column(DateTime(timezone=True), nullable=True)

match_status = relationship("MatchStatus")

Expand Down
3 changes: 2 additions & 1 deletion backend/app/models/Task.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from datetime import datetime
from enum import Enum as PyEnum

from sqlalchemy import Column, DateTime, ForeignKey
from sqlalchemy import Column, DateTime, ForeignKey, Text
from sqlalchemy import Enum as SQLEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
Expand Down Expand Up @@ -69,6 +69,7 @@ class Task(Base):
end_date = Column(DateTime, nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
description = Column(Text, nullable=True)

# Relationships
participant = relationship("User", foreign_keys=[participant_id], backref="participant_tasks")
Expand Down
3 changes: 3 additions & 0 deletions backend/app/models/User.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class User(Base):
auth_id = Column(Text, nullable=False)
approved = Column(Boolean, default=False)
active = Column(Boolean, nullable=False, default=True)
pending_volunteer_request = Column(Boolean, nullable=False, default=False)
form_status = Column(
SQLEnum(
FormStatus,
Expand All @@ -51,3 +52,5 @@ class User(Base):
volunteer_matches = relationship("Match", back_populates="volunteer", foreign_keys=[Match.volunteer_id])

volunteer_data = relationship("VolunteerData", back_populates="user", uselist=False)

user_data = relationship("UserData", back_populates="user", uselist=False)
3 changes: 3 additions & 0 deletions backend/app/models/UserData.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,6 @@ class UserData(Base):
# Loved one many-to-many relationships
loved_one_treatments = relationship("Treatment", secondary=user_loved_one_treatments)
loved_one_experiences = relationship("Experience", secondary=user_loved_one_experiences)

# Back-reference to User
user = relationship("User", back_populates="user_data")
277 changes: 269 additions & 8 deletions backend/app/routes/match.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Optional
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session

from app.schemas.match import SubmitMatchRequest, SubmitMatchResponse
from app.middleware.auth import has_roles
from app.schemas.match import (
MatchCreateRequest,
MatchCreateResponse,
MatchDetailResponse,
MatchListForVolunteerResponse,
MatchListResponse,
MatchRequestNewTimesRequest,
MatchRequestNewVolunteersRequest,
MatchRequestNewVolunteersResponse,
MatchResponse,
MatchScheduleRequest,
MatchUpdateRequest,
)
from app.schemas.task import TaskCreateRequest, TaskType
from app.schemas.user import UserRole
from app.services.implementations.match_service import MatchService
from app.services.implementations.task_service import TaskService
from app.services.implementations.user_service import UserService
from app.utilities.db_utils import get_db
from app.utilities.service_utils import get_task_service, get_user_service

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

Expand All @@ -12,16 +33,256 @@ def get_match_service(db: Session = Depends(get_db)) -> MatchService:
return MatchService(db)


@router.post("/confirm-time", response_model=SubmitMatchResponse)
async def confirm_time(
payload: SubmitMatchRequest,
@router.post("/", response_model=MatchCreateResponse)
async def create_matches(
payload: MatchCreateRequest,
match_service: MatchService = Depends(get_match_service),
_authorized: bool = has_roles([UserRole.ADMIN]),
):
try:
return await match_service.create_matches(payload)
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


@router.put("/{match_id}", response_model=MatchResponse)
async def update_match(
match_id: int,
payload: MatchUpdateRequest,
match_service: MatchService = Depends(get_match_service),
_authorized: bool = has_roles([UserRole.ADMIN]),
):
try:
return await match_service.update_match(match_id, payload)
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


@router.get("/me", response_model=MatchListResponse)
async def get_my_matches(
request: Request,
match_service: MatchService = Depends(get_match_service),
user_service: UserService = Depends(get_user_service),
_authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
):
try:
confirmed_match = await match_service.submit_time(payload)
return confirmed_match
auth_id = getattr(request.state, "user_id", None)
if not auth_id:
raise HTTPException(status_code=401, detail="Unauthorized")

participant_id_str = await user_service.get_user_id_by_auth_id(auth_id)
participant_id = UUID(participant_id_str)
return await match_service.get_matches_for_participant(participant_id)
except HTTPException as http_ex:
raise http_ex
except Exception as e:
print(e)
raise HTTPException(status_code=500, detail=str(e))


@router.get("/participant/{participant_id}", response_model=MatchListResponse)
async def get_matches_for_participant(
participant_id: UUID,
match_service: MatchService = Depends(get_match_service),
_authorized: bool = has_roles([UserRole.ADMIN]),
):
try:
return await match_service.get_matches_for_participant(participant_id)
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


@router.post("/{match_id}/schedule", response_model=MatchDetailResponse)
async def schedule_match(
match_id: int,
payload: MatchScheduleRequest,
request: Request,
match_service: MatchService = Depends(get_match_service),
user_service: UserService = Depends(get_user_service),
_authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
):
try:
acting_participant_id = await _resolve_acting_participant_id(request, user_service)
return await match_service.schedule_match(match_id, payload.time_block_id, acting_participant_id)
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


@router.post("/{match_id}/request-new-times", response_model=MatchDetailResponse)
async def request_new_times(
match_id: int,
payload: MatchRequestNewTimesRequest,
request: Request,
match_service: MatchService = Depends(get_match_service),
user_service: UserService = Depends(get_user_service),
_authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
):
try:
acting_participant_id = await _resolve_acting_participant_id(request, user_service)
return await match_service.request_new_times(match_id, payload.suggested_new_times, acting_participant_id)
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


@router.post("/{match_id}/cancel", response_model=MatchDetailResponse)
async def cancel_match_as_participant(
match_id: int,
request: Request,
match_service: MatchService = Depends(get_match_service),
user_service: UserService = Depends(get_user_service),
_authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
):
try:
acting_participant_id = await _resolve_acting_participant_id(request, user_service)
return await match_service.cancel_match_by_participant(match_id, acting_participant_id)
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


@router.get("/volunteer/me", response_model=MatchListForVolunteerResponse)
async def get_my_matches_as_volunteer(
request: Request,
match_service: MatchService = Depends(get_match_service),
user_service: UserService = Depends(get_user_service),
_authorized: bool = has_roles([UserRole.VOLUNTEER, UserRole.ADMIN]),
):
"""Get all matches for the current volunteer, including those awaiting acceptance."""
try:
auth_id = getattr(request.state, "user_id", None)
if not auth_id:
raise HTTPException(status_code=401, detail="Unauthorized")

volunteer_id_str = await user_service.get_user_id_by_auth_id(auth_id)
volunteer_id = UUID(volunteer_id_str)
return await match_service.get_matches_for_volunteer(volunteer_id)
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


@router.post("/{match_id}/accept-volunteer", response_model=MatchDetailResponse)
async def accept_match_as_volunteer(
match_id: int,
request: Request,
match_service: MatchService = Depends(get_match_service),
user_service: UserService = Depends(get_user_service),
_authorized: bool = has_roles([UserRole.VOLUNTEER, UserRole.ADMIN]),
):
"""Volunteer accepts a match and sends their general availability to participant."""
try:
acting_volunteer_id = await _resolve_acting_volunteer_id(request, user_service)
return await match_service.volunteer_accept_match(match_id, acting_volunteer_id)
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


@router.post("/{match_id}/cancel-volunteer", response_model=MatchDetailResponse)
async def cancel_match_as_volunteer(
match_id: int,
request: Request,
match_service: MatchService = Depends(get_match_service),
user_service: UserService = Depends(get_user_service),
_authorized: bool = has_roles([UserRole.ADMIN, UserRole.VOLUNTEER]),
):
try:
acting_volunteer_id = await _resolve_acting_volunteer_id(request, user_service)
return await match_service.cancel_match_by_volunteer(match_id, acting_volunteer_id)
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


@router.post("/request-new-volunteers", response_model=MatchRequestNewVolunteersResponse)
async def request_new_volunteers(
request: Request,
payload: MatchRequestNewVolunteersRequest,
match_service: MatchService = Depends(get_match_service),
user_service: UserService = Depends(get_user_service),
task_service: TaskService = Depends(get_task_service),
_authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
):
try:
participant_id = payload.participant_id

if participant_id is None:
participant_id = await _resolve_acting_participant_id(request, user_service)
if not participant_id:
raise HTTPException(status_code=400, detail="Participant identity required")
response = await match_service.request_new_volunteers(participant_id, participant_id)
else:
acting_participant_id = await _resolve_acting_participant_id(request, user_service)
response = await match_service.request_new_volunteers(participant_id, acting_participant_id)
task_request = TaskCreateRequest(
participant_id=participant_id,
type=TaskType.MATCHING,
description=payload.message,
)
await task_service.create_task(task_request)

return response
except HTTPException as http_ex:
raise http_ex
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


async def _resolve_acting_participant_id(request: Request, user_service: UserService) -> Optional[UUID]:
auth_id = getattr(request.state, "user_id", None)
if not auth_id:
raise HTTPException(status_code=401, detail="Authentication required")

try:
role_name = user_service.get_user_role_by_auth_id(auth_id)
except ValueError as exc:
raise HTTPException(status_code=401, detail="User not found") from exc

if role_name == UserRole.PARTICIPANT.value:
try:
participant_id_str = await user_service.get_user_id_by_auth_id(auth_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail="Participant not found") from exc
return UUID(participant_id_str)

if role_name == UserRole.ADMIN.value:
# Admin callers bypass ownership checks
return None

raise HTTPException(status_code=403, detail="Insufficient role for participant operation")


async def _resolve_acting_volunteer_id(request: Request, user_service: UserService) -> Optional[UUID]:
auth_id = getattr(request.state, "user_id", None)
if not auth_id:
raise HTTPException(status_code=401, detail="Authentication required")

try:
role_name = user_service.get_user_role_by_auth_id(auth_id)
except ValueError as exc:
raise HTTPException(status_code=401, detail="User not found") from exc

if role_name == UserRole.VOLUNTEER.value:
try:
volunteer_id_str = await user_service.get_user_id_by_auth_id(auth_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail="Volunteer not found") from exc
return UUID(volunteer_id_str)

if role_name == UserRole.ADMIN.value:
return None

raise HTTPException(status_code=403, detail="Insufficient role for volunteer operation")
Loading