From e8cd1f05aa8348337049d6f02d4c0daadc89e29c Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 22:31:16 +0200 Subject: [PATCH] feat(services): add station_service with CRUD and assignment logic Implements create/update/delete station, assign/unassign recipe, list_station_recipes (active only, ordered by code), and get_station_by_code. Explicit ORM-level assignment deletion in delete_station ensures cascade works engine-agnostically (SQLite tests + MySQL production). 10 TDD tests. Co-Authored-By: Claude Sonnet 4.6 --- server/services/station_service.py | 155 +++++++++++++++++++++++++++ server/tests/test_station_service.py | 114 ++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 server/services/station_service.py create mode 100644 server/tests/test_station_service.py diff --git a/server/services/station_service.py b/server/services/station_service.py new file mode 100644 index 0000000..e203247 --- /dev/null +++ b/server/services/station_service.py @@ -0,0 +1,155 @@ +"""Business logic for stations and recipe assignments. + +Routers must call into these functions rather than manipulating models directly. +All functions are async and accept an AsyncSession; they flush but do NOT commit +(commit is handled by the FastAPI get_db dependency). +""" +from typing import Optional + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from models.recipe import Recipe +from models.station import Station, StationRecipeAssignment +from models.user import User +from schemas.station import StationCreate, StationUpdate + + +async def create_station( + db: AsyncSession, data: StationCreate, creator: User, +) -> Station: + existing = await db.execute(select(Station).where(Station.code == data.code)) + if existing.scalar_one_or_none() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Station code '{data.code}' already exists", + ) + station = Station( + code=data.code, + name=data.name, + location=data.location, + notes=data.notes, + active=data.active, + created_by=creator.id, + ) + db.add(station) + await db.flush() + await db.refresh(station) + return station + + +async def get_station(db: AsyncSession, station_id: int) -> Station: + result = await db.execute(select(Station).where(Station.id == station_id)) + station = result.scalar_one_or_none() + if station is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Station not found", + ) + return station + + +async def get_station_by_code(db: AsyncSession, code: str) -> Optional[Station]: + result = await db.execute(select(Station).where(Station.code == code)) + return result.scalar_one_or_none() + + +async def list_stations(db: AsyncSession, active_only: bool = False) -> list[Station]: + query = select(Station).order_by(Station.code) + if active_only: + query = query.where(Station.active == True) # noqa: E712 + result = await db.execute(query) + return list(result.scalars().all()) + + +async def update_station( + db: AsyncSession, station_id: int, data: StationUpdate, +) -> Station: + station = await get_station(db, station_id) + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(station, field, value) + await db.flush() + await db.refresh(station) + return station + + +async def delete_station(db: AsyncSession, station_id: int) -> None: + station = await get_station(db, station_id) + # Explicitly delete assignments so the ORM cascade fires within the + # current session (SQLite test DB does not enforce FK CASCADE without + # PRAGMA foreign_keys = ON; production MySQL handles it at DB level too, + # but explicit ORM deletion is engine-agnostic and safer). + assignments = await db.execute( + select(StationRecipeAssignment).where( + StationRecipeAssignment.station_id == station_id + ) + ) + for assignment in assignments.scalars().all(): + await db.delete(assignment) + await db.delete(station) + await db.flush() + + +async def assign_recipe( + db: AsyncSession, station_id: int, recipe_id: int, assigner: User, +) -> StationRecipeAssignment: + await get_station(db, station_id) + recipe_row = await db.execute(select(Recipe).where(Recipe.id == recipe_id)) + if recipe_row.scalar_one_or_none() is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found", + ) + existing = await db.execute( + select(StationRecipeAssignment).where( + StationRecipeAssignment.station_id == station_id, + StationRecipeAssignment.recipe_id == recipe_id, + ) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Recipe already assigned to this station", + ) + assignment = StationRecipeAssignment( + station_id=station_id, recipe_id=recipe_id, assigned_by=assigner.id, + ) + db.add(assignment) + await db.flush() + await db.refresh(assignment) + return assignment + + +async def unassign_recipe( + db: AsyncSession, station_id: int, recipe_id: int, +) -> None: + result = await db.execute( + select(StationRecipeAssignment).where( + StationRecipeAssignment.station_id == station_id, + StationRecipeAssignment.recipe_id == recipe_id, + ) + ) + assignment = result.scalar_one_or_none() + if assignment is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assignment not found", + ) + await db.delete(assignment) + await db.flush() + + +async def list_station_recipes( + db: AsyncSession, station_id: int, +) -> list[Recipe]: + """Return active recipes assigned to this station, ordered by code.""" + await get_station(db, station_id) + result = await db.execute( + select(Recipe) + .join(StationRecipeAssignment, StationRecipeAssignment.recipe_id == Recipe.id) + .where( + StationRecipeAssignment.station_id == station_id, + Recipe.active == True, # noqa: E712 + ) + .order_by(Recipe.code) + ) + return list(result.scalars().all()) diff --git a/server/tests/test_station_service.py b/server/tests/test_station_service.py new file mode 100644 index 0000000..e232fe0 --- /dev/null +++ b/server/tests/test_station_service.py @@ -0,0 +1,114 @@ +"""Tests for station_service business logic.""" +import pytest +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from models.station import Station +from schemas.station import StationCreate, StationUpdate +from services.station_service import ( + create_station, update_station, delete_station, + assign_recipe, unassign_recipe, list_station_recipes, + get_station_by_code, +) +from tests.conftest import _create_user, create_test_recipe + + +async def test_create_station_ok(db_session: AsyncSession): + admin = await _create_user(db_session, username="a1", is_admin=True) + station = await create_station( + db_session, StationCreate(code="ST-100", name="Pilot"), admin + ) + assert station.id is not None + assert station.code == "ST-100" + + +async def test_create_station_duplicate_code(db_session: AsyncSession): + admin = await _create_user(db_session, username="a2", is_admin=True) + await create_station(db_session, StationCreate(code="ST-DUP", name="A"), admin) + with pytest.raises(HTTPException) as exc: + await create_station(db_session, StationCreate(code="ST-DUP", name="B"), admin) + assert exc.value.status_code == 409 + + +async def test_update_station(db_session: AsyncSession): + admin = await _create_user(db_session, username="a3", is_admin=True) + station = await create_station(db_session, StationCreate(code="ST-U", name="Old"), admin) + updated = await update_station( + db_session, station.id, StationUpdate(name="New name"), + ) + assert updated.name == "New name" + assert updated.code == "ST-U" + + +async def test_update_missing_station(db_session: AsyncSession): + with pytest.raises(HTTPException) as exc: + await update_station(db_session, 9999, StationUpdate(name="x")) + assert exc.value.status_code == 404 + + +async def test_assign_and_list_recipes(db_session: AsyncSession): + admin = await _create_user(db_session, username="a4", is_admin=True) + station = await create_station(db_session, StationCreate(code="ST-R", name="R"), admin) + r1 = await create_test_recipe(db_session, user_id=admin.id, code="REC-R1") + r2 = await create_test_recipe(db_session, user_id=admin.id, code="REC-R2") + await assign_recipe(db_session, station.id, r1.id, admin) + await assign_recipe(db_session, station.id, r2.id, admin) + recipes = await list_station_recipes(db_session, station.id) + assert {r.code for r in recipes} == {"REC-R1", "REC-R2"} + + +async def test_assign_same_recipe_twice_is_409(db_session: AsyncSession): + admin = await _create_user(db_session, username="a5", is_admin=True) + station = await create_station(db_session, StationCreate(code="ST-D", name="D"), admin) + r = await create_test_recipe(db_session, user_id=admin.id, code="REC-D") + await assign_recipe(db_session, station.id, r.id, admin) + with pytest.raises(HTTPException) as exc: + await assign_recipe(db_session, station.id, r.id, admin) + assert exc.value.status_code == 409 + + +async def test_unassign_recipe(db_session: AsyncSession): + admin = await _create_user(db_session, username="a6", is_admin=True) + station = await create_station(db_session, StationCreate(code="ST-UN", name="UN"), admin) + r = await create_test_recipe(db_session, user_id=admin.id, code="REC-UN") + await assign_recipe(db_session, station.id, r.id, admin) + await unassign_recipe(db_session, station.id, r.id) + recipes = await list_station_recipes(db_session, station.id) + assert recipes == [] + + +async def test_get_station_by_code(db_session: AsyncSession): + admin = await _create_user(db_session, username="a7", is_admin=True) + await create_station(db_session, StationCreate(code="ST-FIND", name="F"), admin) + found = await get_station_by_code(db_session, "ST-FIND") + assert found is not None + assert found.name == "F" + missing = await get_station_by_code(db_session, "ST-NOPE") + assert missing is None + + +async def test_list_recipes_only_returns_active(db_session: AsyncSession): + admin = await _create_user(db_session, username="a8", is_admin=True) + station = await create_station(db_session, StationCreate(code="ST-A", name="A"), admin) + active = await create_test_recipe(db_session, user_id=admin.id, code="REC-AC") + inactive = await create_test_recipe(db_session, user_id=admin.id, code="REC-IN") + inactive.active = False + await db_session.flush() + await assign_recipe(db_session, station.id, active.id, admin) + await assign_recipe(db_session, station.id, inactive.id, admin) + recipes = await list_station_recipes(db_session, station.id) + assert [r.code for r in recipes] == ["REC-AC"] + + +async def test_delete_station_cascades_assignments(db_session: AsyncSession): + from sqlalchemy import select + from models.station import StationRecipeAssignment + admin = await _create_user(db_session, username="a9", is_admin=True) + station = await create_station(db_session, StationCreate(code="ST-DEL", name="D"), admin) + r = await create_test_recipe(db_session, user_id=admin.id, code="REC-DEL") + await assign_recipe(db_session, station.id, r.id, admin) + await delete_station(db_session, station.id) + remaining = await db_session.execute( + select(StationRecipeAssignment).where(StationRecipeAssignment.station_id == station.id) + ) + assert remaining.scalars().all() == []