-
Notifications
You must be signed in to change notification settings - Fork 0
Referrals CRUD (DEV-52) #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
12ab7ea
f665acb
ada1e29
853f602
204a6ed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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_422_UNPROCESSABLE_ENTITY, | ||
| 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_422_UNPROCESSABLE_ENTITY, | ||
| 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_422_UNPROCESSABLE_ENTITY, | ||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. really nitpick, but maybe another exception handler, so that it doesn't only handle integrity errors, but any error in general something like that just so we roll it back |
||
| await db.commit() | ||
| except IntegrityError as e: | ||
| await db.rollback() | ||
| raise ValueError(f"Unable to create referral: {str(e.orig)}") from e | ||
|
|
||
| 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. potentially add a check like to avoid committing without modifying anything (#17 for reference) |
||
| 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same here, a more broad exception handler |
||
| await db.rollback() | ||
| raise ValueError(f"Unable to update referral: {str(e.orig)}") from e | ||
|
|
||
| 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same here, a more broad exception handler |
||
| await db.rollback() | ||
| raise ValueError(f"Unable to delete referral: {str(e.orig)}") from e | ||
|
|
||
|
|
||
| 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i like this validation, @kenzysoror out of curiosity could we make this function a little more generic instead of beling specific to referrals so we can do the same validation for other CRUD models asw? like donations, furniture, etc, etc? just a thought
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i like this idea!! it would definitely reduce our code duplication in validating all the models |
||
| """Ensure related client and agency records exist when provided.""" | ||
| if "client_id" in data: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we maybe skip lookups when the FK is |
||
| client_result = await db.execute( | ||
| select(Client).where(Client.id == data["client_id"]) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if all we care about here is checking the existence of the client/agency, then we can |
||
| ) | ||
| 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_result = await db.execute( | ||
| select(Agency).where(Agency.id == data["agency_id"]) | ||
| ) | ||
| if not agency_result.scalar_one_or_none(): | ||
| raise ValueError(f"Agency with ID '{data['agency_id']}' does not exist.") | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it looks like most of the CRUD implementations the team is working on use
HTTP_400_BAD_REQUESToverHTTP_422_UNPROCESSABLE_ENTITY, so i think we should keep it consistent for now