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