diff --git a/backend/app/api/referrals.py b/backend/app/api/referrals.py index af0d3ae..3571f17 100644 --- a/backend/app/api/referrals.py +++ b/backend/app/api/referrals.py @@ -1,23 +1,87 @@ -"""Starter endpoints for the Referrals API. +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.ext.asyncio import AsyncSession -This module contains a minimal placeholder for contributors to extend. -Refer to `docs/STARTER_BACKEND_GUIDE.md` for implementation -examples and guidance on using `AsyncSession`, models, and schemas. -""" - -from fastapi import APIRouter, Response, status +from ..database import get_db +from ..schemas import Referral as ReferralSchema +from ..schemas import ReferralCreate, ReferralUpdate +from ..services import referrals as referrals_service router = APIRouter() -@router.get("") -async def list_referrals(): - """List referrals. +@router.get("", response_model=list[ReferralSchema], status_code=status.HTTP_200_OK) +async def list_referrals(db: AsyncSession = Depends(get_db)): + return await referrals_service.list_referrals(db) + + +@router.post("", response_model=ReferralSchema, status_code=status.HTTP_201_CREATED) +async def create_referral( + payload: ReferralCreate, + db: AsyncSession = Depends(get_db), +): + try: + return await referrals_service.create_referral(db, payload) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + +@router.get("/{id}", response_model=ReferralSchema, status_code=status.HTTP_200_OK) +async def get_referral( + id: str, + db: AsyncSession = Depends(get_db), +): + referral = await referrals_service.get_referral(db, id) + if not referral: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Referral not found", + ) + return referral + + +@router.put("/{id}", response_model=ReferralSchema, status_code=status.HTTP_200_OK) +async def update_referral( + id: str, + payload: ReferralUpdate, + db: AsyncSession = Depends(get_db), +): + referral = await referrals_service.get_referral(db, id) + if not referral: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Referral not found", + ) + + try: + return await referrals_service.update_referral(db, referral, payload) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + +@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_referral( + id: str, + db: AsyncSession = Depends(get_db), +): + referral = await referrals_service.get_referral(db, id) + if not referral: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Referral not found", + ) + + try: + await referrals_service.delete_referral(db, referral) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e - Placeholder implementation. See `docs/STARTER_BACKEND_GUIDE.md` (repo root) - for details on implementing this handler. - """ - return Response( - content="Not implemented — see docs/STARTER_BACKEND_GUIDE.md", - status_code=status.HTTP_501_NOT_IMPLEMENTED, - ) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index ca8bf7f..2bd84cd 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -6,5 +6,6 @@ from . import agencies as agencies_service from . import clients as clients_service from . import donors as donor_service +from . import referrals as referrals_service -__all__ = ["agencies_service", "donor_service", "clients_service"] +__all__ = ["agencies_service", "donor_service", "clients_service", "referrals_service"] diff --git a/backend/app/services/referrals.py b/backend/app/services/referrals.py new file mode 100644 index 0000000..83f13ae --- /dev/null +++ b/backend/app/services/referrals.py @@ -0,0 +1,122 @@ +import json +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Agency, Client, Referral +from ..schemas import ReferralCreate, ReferralUpdate + + +async def list_referrals(db: AsyncSession) -> list[Referral]: + """Return all referrals ordered from newest to oldest.""" + result = await db.execute(select(Referral).order_by(Referral.created_at.desc())) + return result.scalars().all() + + +async def get_referral(db: AsyncSession, referral_id: str) -> Referral | None: + """Return a single referral by ID or None when not found.""" + result = await db.execute(select(Referral).where(Referral.id == referral_id)) + return result.scalar_one_or_none() + + +async def create_referral(db: AsyncSession, payload: ReferralCreate) -> Referral: + """Create a referral after validating related records.""" + data = payload.model_dump(exclude_unset=True) + await _validate_related_records(db, data) + referral = Referral(**_prepare_payload(data)) + db.add(referral) + + try: + await db.commit() + except IntegrityError as e: + await db.rollback() + raise ValueError(f"Unable to create referral: {str(e.orig)}") from e + except Exception: + await db.rollback() + raise + + await db.refresh(referral) + return referral + + +async def update_referral( + db: AsyncSession, referral: Referral, payload: ReferralUpdate +) -> Referral: + """Update an existing referral with validated, normalized data.""" + data = payload.model_dump(exclude_unset=True) + + if not data: + raise ValueError("No update fields were provided.") + + await _validate_related_records(db, data) + + for key, value in _prepare_payload(data).items(): + setattr(referral, key, value) + + try: + await db.commit() + except IntegrityError as e: + await db.rollback() + raise ValueError(f"Unable to update referral: {str(e.orig)}") from e + except Exception: + await db.rollback() + raise + + await db.refresh(referral) + return referral + + +async def delete_referral(db: AsyncSession, referral: Referral) -> None: + """Delete an existing referral.""" + await db.delete(referral) + + try: + await db.commit() + except IntegrityError as e: + await db.rollback() + raise ValueError(f"Unable to delete referral: {str(e.orig)}") from e + except Exception: + await db.rollback() + raise + + +def _prepare_payload(data: dict[str, Any]) -> dict[str, Any]: + """Normalize datetimes and serialize requested_items for persistence.""" + prepared = data.copy() + + if "requested_items" in prepared: + prepared["requested_items"] = json.dumps(prepared["requested_items"]) + + for key, value in prepared.items(): + if isinstance(value, datetime) and value.tzinfo is not None: + prepared[key] = value.astimezone(timezone.utc).replace(tzinfo=None) + + return prepared + + +async def _validate_related_records(db: AsyncSession, data: dict[str, Any]) -> None: + """Ensure related client and agency records exist when provided.""" + if "client_id" in data: + client_id = data["client_id"] + if client_id is None or client_id == "": + raise ValueError(f"Client with ID '{client_id}' does not exist.") + + client_result = await db.execute( + select(Client.id).where(Client.id == client_id) + ) + if not client_result.scalar_one_or_none(): + raise ValueError(f"Client with ID '{data['client_id']}' does not exist.") + + if "agency_id" in data: + agency_id = data["agency_id"] + if agency_id is None or agency_id == "": + raise ValueError(f"Agency with ID '{agency_id}' does not exist.") + + agency_result = await db.execute( + select(Agency.id).where(Agency.id == agency_id) + ) + if not agency_result.scalar_one_or_none(): + raise ValueError(f"Agency with ID '{data['agency_id']}' does not exist.")