diff --git a/backend/app/api/donors.py b/backend/app/api/donors.py index 134b579..9ea5e48 100644 --- a/backend/app/api/donors.py +++ b/backend/app/api/donors.py @@ -1,23 +1,112 @@ -"""Starter endpoints for the Donors API. +"""Donors REST API. -This module contains a minimal placeholder to extend and implement. -Refer to `docs/STARTER_BACKEND_GUIDE.md` for implementation -examples and guidance on using `AsyncSession`, models, and schemas. +Full CRUD implementation for the Donors resource. """ -from fastapi import APIRouter, Response, status +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from ..database import get_db +from ..schemas import DonorCreate, DonorUpdate, Donor as DonorSchema +from ..services import donor_service router = APIRouter() -@router.get("") -async def list_donors(): - """List donors. +@router.get("", response_model=list[DonorSchema]) +async def list_donors(db: AsyncSession = Depends(get_db)): + """List all donors.""" + return await donor_service.list_donors(db) + + +@router.post( + "", + response_model=DonorSchema, + status_code=status.HTTP_201_CREATED, + responses={400: {"description": "Integrity error (e.g., duplicate donor email)"}}, +) +async def create_donor(donor: DonorCreate, db: AsyncSession = Depends(get_db)): + """Create a new donor.""" + try: + return await donor_service.create_donor(db, donor) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + +@router.get( + "/{donor_id}", + response_model=DonorSchema, + status_code=status.HTTP_200_OK, + responses={404: {"description": "Donor not found"}}, +) +async def get_donor(donor_id: str, db: AsyncSession = Depends(get_db)): + """Get a single donor by ID.""" + donor = await donor_service.get_donor(db, donor_id) + if not donor: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Donor not found", + ) + return donor + + +@router.put( + "/{donor_id}", + response_model=DonorSchema, + status_code=status.HTTP_200_OK, + responses={ + 400: {"description": "Integrity error (e.g., duplicate donor email)"}, + 404: {"description": "Donor not found"}, + }, +) +async def update_donor( + donor_id: str, payload: DonorUpdate, db: AsyncSession = Depends(get_db) +): + """Update a donor.""" + donor = await donor_service.get_donor(db, donor_id) + if not donor: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Donor not found", + ) + + try: + return await donor_service.update_donor(db, donor, payload) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + +@router.delete( + "/{donor_id}", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + 400: { + "description": "Integrity error (e.g., donor has linked furniture records)" + }, + 404: {"description": "Donor not found"}, + }, +) +async def delete_donor(donor_id: str, db: AsyncSession = Depends(get_db)): + """Delete a donor.""" + donor = await donor_service.get_donor(db, donor_id) + if not donor: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Donor not found", + ) + + try: + await donor_service.delete_donor(db, donor) + 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 ca9fd32..cb51a80 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,8 +1,9 @@ -"""Services package. - -Business logic layer; API routes depend on services, not the reverse. -""" - -from . import clients as clients_service - -__all__ = ["clients_service"] +"""Services package. + +Business logic layer; API routes depend on services, not the reverse. +""" + +from . import donors as donor_service +from . import clients as clients_service + +__all__ = ["donor_service", "clients_service"] diff --git a/backend/app/services/donors.py b/backend/app/services/donors.py new file mode 100644 index 0000000..07351dc --- /dev/null +++ b/backend/app/services/donors.py @@ -0,0 +1,70 @@ +"""Donor services module. + +Contains business logic for donor-related operations. +""" + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from ..models import Donor +from ..schemas import DonorCreate, DonorUpdate + + +async def list_donors(db: AsyncSession) -> list[Donor]: + """List all donors.""" + result = await db.execute(select(Donor).order_by(Donor.name)) + return result.scalars().all() + + +async def create_donor(db: AsyncSession, donor: DonorCreate) -> Donor: + """Create a new donor.""" + try: + db_donor = Donor(**donor.model_dump()) + db.add(db_donor) + await db.commit() + await db.refresh(db_donor) + return db_donor + except IntegrityError as e: + await db.rollback() + raise ValueError(f"Unable to create donor: {str(e.orig)}") from e + except Exception: + await db.rollback() + raise + + +async def get_donor(db: AsyncSession, donor_id: str) -> Donor | None: + """Get a single donor by ID.""" + result = await db.execute(select(Donor).where(Donor.id == donor_id)) + return result.scalar_one_or_none() + + +async def update_donor(db: AsyncSession, donor: Donor, payload: DonorUpdate) -> Donor: + """Update a donor.""" + try: + # Only update fields that were actually provided in the request + update_data = payload.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(donor, key, value) + + await db.commit() + await db.refresh(donor) + return donor + except IntegrityError as e: + await db.rollback() + raise ValueError(f"Unable to update donor: {str(e.orig)}") from e + except Exception: + await db.rollback() + raise + + +async def delete_donor(db: AsyncSession, donor: Donor) -> None: + """Delete a donor.""" + try: + await db.delete(donor) + await db.commit() + except IntegrityError as e: + await db.rollback() + raise ValueError(f"Unable to delete donor: {str(e.orig)}") from e + except Exception: + await db.rollback() + raise