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 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 22:31:16 +02:00
parent 559e740d64
commit e8cd1f05aa
2 changed files with 269 additions and 0 deletions
+155
View File
@@ -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())