Files
TieMeasureFlow/src/backend/services/station_service.py
T
Adriano Dal Pastro a159eb2b8b feat(stations): ricette in lista, reset per riga e cambio stazione al volo
Punto 7 del documento modifiche del 28/07, terza richiesta di Menoncin.

GET /api/stations ora usa StationWithRecipesResponse, che era gia' definita nello
schema senza che nessun endpoint la usasse. Non costa query in piu':
Station.assignments e' gia' selectin-loaded. In lista compaiono le sole ricette
attive, coerente con cio' che l'operatore vede davvero a quella stazione.

DELETE /api/stations/{id}/recipes azzera le assegnazioni di una stazione senza
eliminarla, cosi' si riassegna da capo. E' idempotente: azzerare una stazione gia'
vuota risponde removed=0, non 404. In tabella e' un pulsante per riga, con
conferma perche' l'azione e' distruttiva.

Il cambio stazione al volo (?station=CODE, override in sessione) serve al
collaudo: senza, provare N stazioni richiede N PC. E' dietro il flag
STATION_SWITCH_ENABLED, default 0. Non e' legato al ruolo admin perche'
/measure/select richiede MeasurementTec, e gatearlo su is_admin avrebbe escluso
proprio chi fa il collaudo. In fabbrica l'identita' della stazione viene
dall'installazione locale, e misurare contro le ricette di un'altra stazione
romperebbe in silenzio la tracciabilita': per questo il codice richiesto e'
validato prima di essere memorizzato - un refuso lascia l'operatore sulla
stazione configurata con un messaggio, invece di incastrarlo su una stazione
inesistente - e quando l'override e' attivo la pagina lo dichiara, con il codice
configurato accanto e un link per ripristinare.

Le traduzioni: pybabel update aveva indovinato dieci voci da stringhe simili
marcandole fuzzy, in entrambi i cataloghi. "Azzera Stazione" era diventato "Crea
Stazione", l'opposto, su un dialogo distruttivo. Non si vedeva perche' la
compilazione salta le fuzzy e ricade sul sorgente italiano, ma sarebbe emerso al
primo build con --use-fuzzy. Tradotte per esteso le 17 stringhe nuove in IT ed EN
e tolti i flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:42:17 +00:00

176 lines
6.0 KiB
Python

"""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 src.backend.models.orm.recipe import Recipe
from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.models.orm.user import User
from src.backend.models.api.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 unassign_all_recipes(db: AsyncSession, station_id: int) -> int:
"""Drop every recipe assignment of a station and return how many were removed.
Used by the per-row reset in the admin station list: the station goes back to
having no recipes so it can be reassigned from scratch. Unlike unassign_recipe
this is idempotent - resetting an already empty station returns 0 rather than 404.
"""
await get_station(db, station_id)
result = await db.execute(
select(StationRecipeAssignment).where(
StationRecipeAssignment.station_id == station_id
)
)
assignments = list(result.scalars().all())
for assignment in assignments:
await db.delete(assignment)
await db.flush()
return len(assignments)
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())