diff --git a/backend/app/api/agencies.py b/backend/app/api/agencies.py index 5dfc5c7..03c89c1 100644 --- a/backend/app/api/agencies.py +++ b/backend/app/api/agencies.py @@ -4,13 +4,13 @@ Use this module as the reference pattern for other resources. """ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select +from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.ext.asyncio import AsyncSession from ..database import get_db -from ..models import Agency -from ..schemas import AgencyCreate, AgencyUpdate, Agency as AgencySchema +from ..schemas import Agency as AgencySchema +from ..schemas import AgencyCreate, AgencyUpdate +from ..services import agency_service router = APIRouter() @@ -18,25 +18,25 @@ @router.get("", response_model=list[AgencySchema]) async def list_agencies(db: AsyncSession = Depends(get_db)): """List all agencies.""" - result = await db.execute(select(Agency).order_by(Agency.name)) - return list(result.scalars().all()) + return await agency_service.list_agencies(db) @router.post("", response_model=AgencySchema, status_code=status.HTTP_201_CREATED) async def create_agency(agency: AgencyCreate, db: AsyncSession = Depends(get_db)): """Create a new agency.""" - db_agency = Agency(**agency.model_dump()) - db.add(db_agency) - await db.commit() - await db.refresh(db_agency) - return db_agency + try: + return await agency_service.create_agency(db, agency) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e @router.get("/{agency_id}", response_model=AgencySchema) async def get_agency(agency_id: str, db: AsyncSession = Depends(get_db)): """Get a single agency by ID.""" - result = await db.execute(select(Agency).where(Agency.id == agency_id)) - agency = result.scalar_one_or_none() + agency = await agency_service.get_agency(db, agency_id) if not agency: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -52,30 +52,36 @@ async def update_agency( db: AsyncSession = Depends(get_db), ): """Update an agency.""" - result = await db.execute(select(Agency).where(Agency.id == agency_id)) - agency = result.scalar_one_or_none() + agency = await agency_service.get_agency(db, agency_id) if not agency: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Agency not found", ) - data = payload.model_dump(exclude_unset=True) - for key, value in data.items(): - setattr(agency, key, value) - await db.commit() - await db.refresh(agency) - return agency + try: + return await agency_service.update_agency(db, agency, payload) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e @router.delete("/{agency_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_agency(agency_id: str, db: AsyncSession = Depends(get_db)): """Delete an agency.""" - result = await db.execute(select(Agency).where(Agency.id == agency_id)) - agency = result.scalar_one_or_none() + agency = await agency_service.get_agency(db, agency_id) if not agency: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Agency not found", ) - await db.delete(agency) - await db.commit() + try: + await agency_service.delete_agency(db, agency) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 04632a8..7f03999 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -1,17 +1,88 @@ """Routes REST API. -Endpoints for dispatch routes (pickup/dropoff furniture lists). +Full CRUD implementation for the Routes 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 Route as RouteSchema +from ..schemas import RouteCreate, RouteUpdate +from ..services import route_service router = APIRouter() -@router.get("") -async def list_routes(): - """List routes. Placeholder — implement with Route model and schemas.""" - return Response( - content="Not implemented — see docs/STARTER_BACKEND_GUIDE.md", - status_code=status.HTTP_501_NOT_IMPLEMENTED, - ) +@router.get("", response_model=list[RouteSchema]) +async def list_routes(db: AsyncSession = Depends(get_db)): + """List all routes.""" + return await route_service.list_routes(db) + + +@router.post("", response_model=RouteSchema, status_code=status.HTTP_201_CREATED) +async def create_route(payload: RouteCreate, db: AsyncSession = Depends(get_db)): + """Create a new route.""" + try: + return await route_service.create_route(db, payload) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + +@router.get("/{route_id}", response_model=RouteSchema) +async def get_route(route_id: str, db: AsyncSession = Depends(get_db)): + """Get a single route by ID.""" + route = await route_service.get_route(db, route_id) + if not route: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Route not found", + ) + return route + + +@router.put("/{route_id}", response_model=RouteSchema) +async def update_route( + route_id: str, + payload: RouteUpdate, + db: AsyncSession = Depends(get_db), +): + """Update a route.""" + route = await route_service.get_route(db, route_id) + if not route: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Route not found", + ) + + try: + return await route_service.update_route(db, route, payload) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + +@router.delete("/{route_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_route(route_id: str, db: AsyncSession = Depends(get_db)): + """Delete a route.""" + route = await route_service.get_route(db, route_id) + if not route: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Route not found", + ) + + try: + await route_service.delete_route(db, route) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/config.py b/backend/app/config.py index f1e540d..7362bd2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,9 +8,10 @@ """ from functools import lru_cache -from pydantic_settings import BaseSettings from typing import List +from pydantic_settings import BaseSettings + class Settings(BaseSettings): """Application settings.""" diff --git a/backend/app/database.py b/backend/app/database.py index 24254ad..f236726 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -6,7 +6,7 @@ @see https://docs.sqlalchemy.org/en/20/orm/ """ -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import declarative_base from .config import get_settings diff --git a/backend/app/main.py b/backend/app/main.py index 44353f0..2fd1728 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,12 +5,13 @@ dependencies configured. """ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager -from .config import get_settings from .api import router as api_router +from .config import get_settings settings = get_settings() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index faf8b7d..fec2153 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,15 +1,6 @@ """Models package initialization.""" -from .base import ( - Admin, - Agency, - Agent, - Client, - Donor, - Furniture, - Referral, - Route, -) +from .base import Admin, Agency, Agent, Client, Donor, Furniture, Referral, Route __all__ = [ "Admin", diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index cb51a80..a6ab90e 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -3,7 +3,10 @@ Business logic layer; API routes depend on services, not the reverse. """ +from . import agencies as agency_service +from . import routes as route_service from . import donors as donor_service from . import clients as clients_service +__all__ = ["agency_service", "route_service"] __all__ = ["donor_service", "clients_service"] diff --git a/backend/app/services/agencies.py b/backend/app/services/agencies.py new file mode 100644 index 0000000..7ebb33a --- /dev/null +++ b/backend/app/services/agencies.py @@ -0,0 +1,74 @@ +"""Agency services module. + +Contains business logic for agency-related operations. +""" + +import logging + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +from ..models import Agency +from ..schemas import AgencyCreate, AgencyUpdate + + +async def list_agencies(db: AsyncSession) -> list[Agency]: + """List all agencies ordered by name.""" + result = await db.execute(select(Agency).order_by(Agency.name)) + return list(result.scalars().all()) + + +async def get_agency(db: AsyncSession, agency_id: str) -> Agency | None: + """Get a single agency by ID, or None when not found.""" + result = await db.execute(select(Agency).where(Agency.id == agency_id)) + return result.scalar_one_or_none() + + +async def create_agency(db: AsyncSession, payload: AgencyCreate) -> Agency: + """Create a new agency.""" + db_agency = Agency(**payload.model_dump()) + db.add(db_agency) + try: + await db.commit() + except IntegrityError as e: + await db.rollback() + logger.exception("IntegrityError creating agency: %s", e.orig) + raise ValueError("Unable to create agency due to a data conflict.") from e + await db.refresh(db_agency) + return db_agency + + +async def update_agency( + db: AsyncSession, agency: Agency, payload: AgencyUpdate +) -> Agency: + """Update an existing agency.""" + data = payload.model_dump(exclude_unset=True) + + if not data: + raise ValueError("No update fields were provided.") + + for key, value in data.items(): + setattr(agency, key, value) + + try: + await db.commit() + except IntegrityError as e: + await db.rollback() + logger.exception("IntegrityError updating agency %s: %s", agency.id, e.orig) + raise ValueError("Update failed due to a data conflict.") from e + await db.refresh(agency) + return agency + + +async def delete_agency(db: AsyncSession, agency: Agency) -> None: + """Delete an existing agency.""" + await db.delete(agency) + try: + await db.commit() + except IntegrityError as e: + await db.rollback() + logger.exception("IntegrityError deleting agency %s: %s", agency.id, e.orig) + raise ValueError("Unable to delete agency due to a data conflict.") from e diff --git a/backend/app/services/routes.py b/backend/app/services/routes.py new file mode 100644 index 0000000..2b3e985 --- /dev/null +++ b/backend/app/services/routes.py @@ -0,0 +1,80 @@ +"""Route services module. + +Contains business logic for route-related operations. +""" + +import logging +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +from ..models import Route +from ..schemas import RouteCreate, RouteUpdate + + +async def list_routes(db: AsyncSession) -> list[Route]: + """List all routes ordered by date.""" + result = await db.execute(select(Route).order_by(Route.date)) + return list(result.scalars().all()) + + +async def get_route(db: AsyncSession, route_id: str) -> Route | None: + """Get a single route by ID, or None when not found.""" + result = await db.execute(select(Route).where(Route.id == route_id)) + return result.scalar_one_or_none() + + +async def create_route(db: AsyncSession, payload: RouteCreate) -> Route: + """Create a new route.""" + data = payload.model_dump(exclude_unset=True) + for key, value in data.items(): + if isinstance(value, datetime) and value.tzinfo is not None: + data[key] = value.astimezone(timezone.utc).replace(tzinfo=None) + + db_route = Route(**data) + db.add(db_route) + try: + await db.commit() + except IntegrityError as e: + await db.rollback() + logger.exception("IntegrityError creating route: %s", e.orig) + raise ValueError("Unable to create route due to a data conflict.") from e + await db.refresh(db_route) + return db_route + + +async def update_route(db: AsyncSession, route: Route, payload: RouteUpdate) -> Route: + """Update an existing route, normalizing datetimes and handling partial payloads.""" + data = payload.model_dump(exclude_unset=True) + + if not data: + raise ValueError("No update fields were provided.") + + for key, value in data.items(): + if isinstance(value, datetime) and value.tzinfo is not None: + value = value.astimezone(timezone.utc).replace(tzinfo=None) + setattr(route, key, value) + + try: + await db.commit() + except IntegrityError as e: + await db.rollback() + logger.exception("IntegrityError updating route %s: %s", route.id, e.orig) + raise ValueError("Update failed due to a data conflict.") from e + await db.refresh(route) + return route + + +async def delete_route(db: AsyncSession, route: Route) -> None: + """Delete an existing route.""" + await db.delete(route) + try: + await db.commit() + except IntegrityError as e: + await db.rollback() + logger.exception("IntegrityError deleting route %s: %s", route.id, e.orig) + raise ValueError("Unable to delete route due to a data conflict.") from e diff --git a/backend/migrations/env.py b/backend/migrations/env.py index a22bbb6..6998802 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -19,8 +19,8 @@ # Import application settings and models so alembic can autogenerate try: - from app.config import get_settings from app import database # ensures Base is defined + from app.config import get_settings from app.models import base # register all models with Base.metadata except Exception: # pragma: no cover - keep safe for CI raise RuntimeError( diff --git a/backend/migrations/versions/20260215_complete_schema_redesign.py b/backend/migrations/versions/20260215_complete_schema_redesign.py index 1262f10..a2a585b 100644 --- a/backend/migrations/versions/20260215_complete_schema_redesign.py +++ b/backend/migrations/versions/20260215_complete_schema_redesign.py @@ -6,8 +6,8 @@ """ -from alembic import op import sqlalchemy as sa +from alembic import op revision = "20260215_redesign" down_revision = "20260215_hafb" diff --git a/backend/migrations/versions/20260215_hafb_tables.py b/backend/migrations/versions/20260215_hafb_tables.py index fdbf394..652fa14 100644 --- a/backend/migrations/versions/20260215_hafb_tables.py +++ b/backend/migrations/versions/20260215_hafb_tables.py @@ -6,8 +6,8 @@ """ -from alembic import op import sqlalchemy as sa +from alembic import op revision = "20260215_hafb" down_revision = "2adf14c14d9c" diff --git a/backend/migrations/versions/2adf14c14d9c_initial.py b/backend/migrations/versions/2adf14c14d9c_initial.py index 0ab6866..a64ef9b 100644 --- a/backend/migrations/versions/2adf14c14d9c_initial.py +++ b/backend/migrations/versions/2adf14c14d9c_initial.py @@ -6,8 +6,8 @@ """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "2adf14c14d9c" diff --git a/backend/server.py b/backend/server.py index 3e6d3b7..6a6ad60 100644 --- a/backend/server.py +++ b/backend/server.py @@ -5,6 +5,7 @@ """ import os + import uvicorn from dotenv import load_dotenv diff --git a/backend/setup.py b/backend/setup.py index 3cdbd54..c94c70b 100644 --- a/backend/setup.py +++ b/backend/setup.py @@ -1,3 +1,3 @@ -from setuptools import setup, find_packages +from setuptools import find_packages, setup setup(name="app", packages=find_packages()) diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 31aaa3a..6388c86 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -34,7 +34,6 @@ export default function HomePage() { -