Skip to content
Open
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
98 changes: 81 additions & 17 deletions backend/app/api/referrals.py
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,
Copy link
Member

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_REQUEST over HTTP_422_UNPROCESSABLE_ENTITY, so i think we should keep it consistent for now

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)
3 changes: 2 additions & 1 deletion backend/app/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@

from . import donors as donor_service
from . import clients as clients_service
from . import referrals as referrals_service

__all__ = ["donor_service", "clients_service"]
__all__ = ["donor_service", "clients_service", "referrals_service"]
101 changes: 101 additions & 0 deletions backend/app/services/referrals.py
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:
Copy link
Contributor

Choose a reason for hiding this comment

The 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

    except Exception:
        await db.rollback()
        raise 

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

potentially add a check like

if not data:
    raise ValueError("No update fields were provided.")

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:
Copy link
Contributor

Choose a reason for hiding this comment

The 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:
Copy link
Contributor

Choose a reason for hiding this comment

The 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:
Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The 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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we maybe skip lookups when the FK is None or an empty string to avoid hitting the database for
"obviously" invalid values?

client_result = await db.execute(
select(Client).where(Client.id == data["client_id"])
Copy link
Member

Choose a reason for hiding this comment

The 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 select(Client.id) and select(Agency.id) instead of the full rows

)
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.")