Files
TieMeasureFlow/docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md
T
Adriano f71bbf398e docs: add rev04 migration master roadmap and phase1 plan
Aggiunge il master plan di migrazione V1.0.7 -> V1.1.0 (rev04-2026)
con le sette fasi organizzate in milestone M1 (demo cliente) e M2
(produzione), piu il piano TDD dettagliato della Fase 1 (Stazioni e
identita per-tablet) con 17 task eseguibili.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:13:01 +02:00

74 KiB

Fase 1 — Stazioni e Identità per-tablet (Implementation Plan)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Introdurre il concetto di "stazione di controllo" come prima classe del dominio: ogni container client Flask parte con un STATION_CODE nel .env, il server conosce le stazioni e le associazioni stazione-ricetta, e ogni client vede solo le ricette della propria stazione.

Architecture:

  • Due nuove tabelle SQL: stations (anagrafica) e station_recipe_assignments (molti-a-molti stazione↔ricetta).
  • CRUD admin su /api/stations (solo admin).
  • Nuovo endpoint GET /api/stations/by-code/{code}/recipes che ritorna solo le ricette assegnate alla stazione richiesta; autenticato con l'API key dell'operatore loggato (non serve station-specific auth in M1).
  • Il client Flask legge STATION_CODE dal .env e lo usa quando chiama il server in measure (select_recipe). Se assente → pagina di errore di configurazione (fail fast).
  • UI admin per creare stazioni e gestire assegnazioni ricetta→stazione.
  • Seed: una stazione di default ST-DEFAULT con tutte le ricette attive assegnate, così il sistema esistente continua a funzionare dopo la migration.

Tech Stack: SQLAlchemy 2.0 async, Alembic, Pydantic v2, FastAPI, Flask, Jinja2+Tailwind+Alpine, pytest-asyncio, httpx.

Conventions invariate dal codice esistente:

  • Migration Alembic in server/migrations/versions/, next ID 002_add_stations.
  • Modelli usano Mapped[...] + mapped_column(), Base da database.
  • Schema Pydantic con ConfigDict(from_attributes=True) nelle Response.
  • Router: APIRouter(prefix="/api/...", tags=[...]), Depends(get_db), Depends(require_admin_user), response_model diretto (no envelope).
  • Service: logica business async che riceve AsyncSession, solleva HTTPException su conflitti.
  • Test: client, db_session, fixture utenti (admin_user, maker_user, measurement_tec_user), helper auth_headers(user), helper create_test_recipe(session, user_id).
  • Client blueprint admin: route Flask che fa proxy JSON al server via api_client.get/post/put/delete(), error normalization {"error": True, "status_code": ..., "detail": "..."}.
  • i18n: {{ _('...') }} nei template.

Task 1: Alembic Migration 002 — Create stations and station_recipe_assignments Tables

Files:

  • Create: server/migrations/versions/002_add_stations.py

  • Step 1: Write migration file

# server/migrations/versions/002_add_stations.py
"""add stations and station_recipe_assignments tables

Revision ID: 002_add_stations
Revises: 001_image_path
Create Date: 2026-04-17

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

revision: str = '002_add_stations'
down_revision: Union[str, None] = '001_image_path'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    op.create_table(
        'stations',
        sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
        sa.Column('code', sa.String(100), nullable=False),
        sa.Column('name', sa.String(255), nullable=False),
        sa.Column('location', sa.String(255), nullable=True),
        sa.Column('notes', sa.Text, nullable=True),
        sa.Column('active', sa.Boolean, nullable=False, server_default=sa.true()),
        sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
        sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
        sa.UniqueConstraint('code', name='uq_stations_code'),
        sa.Index('ix_stations_code', 'code'),
        sa.Index('ix_stations_active', 'active'),
        mysql_engine='InnoDB',
        mysql_charset='utf8mb4',
    )

    op.create_table(
        'station_recipe_assignments',
        sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
        sa.Column('station_id', sa.Integer, sa.ForeignKey('stations.id', ondelete='CASCADE'), nullable=False),
        sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id', ondelete='CASCADE'), nullable=False),
        sa.Column('assigned_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
        sa.Column('assigned_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
        sa.UniqueConstraint('station_id', 'recipe_id', name='uq_station_recipe'),
        sa.Index('ix_sra_station', 'station_id'),
        sa.Index('ix_sra_recipe', 'recipe_id'),
        mysql_engine='InnoDB',
        mysql_charset='utf8mb4',
    )


def downgrade() -> None:
    op.drop_table('station_recipe_assignments')
    op.drop_table('stations')
  • Step 2: Verify migration is detected

Run: cd server && alembic -c migrations/alembic.ini history Expected output includes both 001_image_path and 002_add_stations.

  • Step 3: Commit
git add server/migrations/versions/002_add_stations.py
git commit -m "feat(db): add migration 002 for stations and assignments"

Task 2: Station and StationRecipeAssignment Models

Files:

  • Create: server/models/station.py

  • Modify: server/tests/conftest.py (import new models so Base.metadata.create_all picks them up)

  • Test: server/tests/test_station_model.py

  • Step 1: Write failing test

# server/tests/test_station_model.py
"""Test the Station and StationRecipeAssignment ORM models."""
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from models.station import Station, StationRecipeAssignment
from tests.conftest import _create_user, create_test_recipe


@pytest.mark.asyncio
async def test_create_station(db_session: AsyncSession):
    admin = await _create_user(db_session, username="admin1", is_admin=True)
    station = Station(
        code="ST-001",
        name="Linea 1",
        location="Reparto Nord",
        created_by=admin.id,
    )
    db_session.add(station)
    await db_session.flush()
    await db_session.refresh(station)
    assert station.id is not None
    assert station.active is True
    assert station.created_at is not None


@pytest.mark.asyncio
async def test_station_code_is_unique(db_session: AsyncSession):
    from sqlalchemy.exc import IntegrityError
    admin = await _create_user(db_session, username="admin2", is_admin=True)
    db_session.add(Station(code="ST-DUP", name="A", created_by=admin.id))
    await db_session.flush()
    db_session.add(Station(code="ST-DUP", name="B", created_by=admin.id))
    with pytest.raises(IntegrityError):
        await db_session.flush()


@pytest.mark.asyncio
async def test_assign_recipe_to_station(db_session: AsyncSession):
    admin = await _create_user(db_session, username="admin3", is_admin=True)
    station = Station(code="ST-002", name="Linea 2", created_by=admin.id)
    db_session.add(station)
    await db_session.flush()
    recipe = await create_test_recipe(db_session, user_id=admin.id, code="REC-X")
    assignment = StationRecipeAssignment(
        station_id=station.id, recipe_id=recipe.id, assigned_by=admin.id,
    )
    db_session.add(assignment)
    await db_session.flush()
    result = await db_session.execute(
        select(StationRecipeAssignment).where(
            StationRecipeAssignment.station_id == station.id
        )
    )
    assignments = result.scalars().all()
    assert len(assignments) == 1
    assert assignments[0].recipe_id == recipe.id
  • Step 2: Run test to verify it fails

Run: cd server && pytest tests/test_station_model.py -v Expected: FAIL with ModuleNotFoundError: No module named 'models.station'.

  • Step 3: Write the model file
# server/models/station.py
"""Station and StationRecipeAssignment models.

A Station represents a physical control point (typically one per tablet/PC).
Recipes are assigned to stations so that each station only sees the products
it is supposed to inspect.
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional

from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

from database import Base

if TYPE_CHECKING:
    from models.recipe import Recipe


class Station(Base):
    __tablename__ = "stations"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
    name: Mapped[str] = mapped_column(String(255), nullable=False)
    location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
    notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
    created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
    created_at: Mapped[datetime] = mapped_column(
        DateTime, nullable=False, server_default=func.now()
    )

    assignments: Mapped[list["StationRecipeAssignment"]] = relationship(
        back_populates="station", cascade="all, delete-orphan", lazy="selectin"
    )

    __table_args__ = (
        {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
    )

    def __repr__(self) -> str:
        return f"<Station {self.code} '{self.name}'>"


class StationRecipeAssignment(Base):
    __tablename__ = "station_recipe_assignments"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    station_id: Mapped[int] = mapped_column(
        Integer, ForeignKey("stations.id", ondelete="CASCADE"), nullable=False, index=True
    )
    recipe_id: Mapped[int] = mapped_column(
        Integer, ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True
    )
    assigned_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
    assigned_at: Mapped[datetime] = mapped_column(
        DateTime, nullable=False, server_default=func.now()
    )

    station: Mapped["Station"] = relationship(back_populates="assignments")
    recipe: Mapped["Recipe"] = relationship(lazy="selectin")

    __table_args__ = (
        UniqueConstraint("station_id", "recipe_id", name="uq_station_recipe"),
        {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
    )

    def __repr__(self) -> str:
        return f"<StationRecipeAssignment station={self.station_id} recipe={self.recipe_id}>"
  • Step 4: Import the new models in conftest

Modify server/tests/conftest.py — add this import near the existing model imports (around line 41):

from models.station import Station, StationRecipeAssignment
  • Step 5: Run test to verify it passes

Run: cd server && pytest tests/test_station_model.py -v Expected: 3 passed.

  • Step 6: Commit
git add server/models/station.py server/tests/test_station_model.py server/tests/conftest.py
git commit -m "feat(models): add Station and StationRecipeAssignment models"

Task 3: Pydantic Schemas for Station

Files:

  • Create: server/schemas/station.py

  • Test: server/tests/test_station_schemas.py

  • Step 1: Write failing test

# server/tests/test_station_schemas.py
"""Tests for Station Pydantic schemas."""
import pytest
from pydantic import ValidationError

from schemas.station import (
    StationCreate, StationUpdate, StationResponse,
    StationRecipeAssignmentCreate, StationRecipeAssignmentResponse,
    StationWithRecipesResponse,
)


def test_station_create_valid():
    data = StationCreate(code="ST-001", name="Linea 1", location="Reparto Nord")
    assert data.code == "ST-001"
    assert data.active is True


def test_station_create_rejects_empty_code():
    with pytest.raises(ValidationError):
        StationCreate(code="", name="X")


def test_station_create_rejects_too_long_code():
    with pytest.raises(ValidationError):
        StationCreate(code="A" * 101, name="X")


def test_station_update_all_optional():
    data = StationUpdate()
    assert data.name is None


def test_station_assignment_create():
    data = StationRecipeAssignmentCreate(recipe_id=42)
    assert data.recipe_id == 42
  • Step 2: Run test to verify it fails

Run: cd server && pytest tests/test_station_schemas.py -v Expected: FAIL with ModuleNotFoundError: No module named 'schemas.station'.

  • Step 3: Write the schemas
# server/schemas/station.py
"""Pydantic schemas for Station and StationRecipeAssignment."""
from datetime import datetime
from typing import Optional

from pydantic import BaseModel, ConfigDict, Field


class StationCreate(BaseModel):
    code: str = Field(..., min_length=1, max_length=100)
    name: str = Field(..., min_length=1, max_length=255)
    location: Optional[str] = Field(default=None, max_length=255)
    notes: Optional[str] = None
    active: bool = True


class StationUpdate(BaseModel):
    name: Optional[str] = Field(default=None, min_length=1, max_length=255)
    location: Optional[str] = Field(default=None, max_length=255)
    notes: Optional[str] = None
    active: Optional[bool] = None


class StationResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    code: str
    name: str
    location: Optional[str]
    notes: Optional[str]
    active: bool
    created_by: int
    created_at: datetime


class StationRecipeAssignmentCreate(BaseModel):
    recipe_id: int = Field(..., gt=0)


class StationRecipeAssignmentResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    station_id: int
    recipe_id: int
    assigned_by: int
    assigned_at: datetime


class _RecipeSummary(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    code: str
    name: str
    active: bool


class StationWithRecipesResponse(StationResponse):
    recipes: list[_RecipeSummary] = Field(default_factory=list)
  • Step 4: Run test to verify it passes

Run: cd server && pytest tests/test_station_schemas.py -v Expected: 5 passed.

  • Step 5: Commit
git add server/schemas/station.py server/tests/test_station_schemas.py
git commit -m "feat(schemas): add Station and assignment Pydantic schemas"

Task 4: Station Service (Business Logic)

Files:

  • Create: server/services/station_service.py

  • Test: server/tests/test_station_service.py

  • Step 1: Write failing test

# server/tests/test_station_service.py
"""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


@pytest.mark.asyncio
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"


@pytest.mark.asyncio
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


@pytest.mark.asyncio
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"


@pytest.mark.asyncio
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


@pytest.mark.asyncio
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"}


@pytest.mark.asyncio
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


@pytest.mark.asyncio
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 == []


@pytest.mark.asyncio
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


@pytest.mark.asyncio
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"]


@pytest.mark.asyncio
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() == []
  • Step 2: Run test to verify it fails

Run: cd server && pytest tests/test_station_service.py -v Expected: FAIL with ModuleNotFoundError: No module named 'services.station_service'.

  • Step 3: Write the service
# server/services/station_service.py
"""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)
    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)
    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,
        )
        .order_by(Recipe.code)
    )
    return list(result.scalars().all())
  • Step 4: Run test to verify it passes

Run: cd server && pytest tests/test_station_service.py -v Expected: 10 passed.

  • Step 5: Commit
git add server/services/station_service.py server/tests/test_station_service.py
git commit -m "feat(services): add station_service with CRUD and assignment logic"

Task 5: Router /api/stations (CRUD, Admin Only)

Files:

  • Create: server/routers/stations.py

  • Modify: server/main.py (include the router)

  • Test: server/tests/test_stations_api.py

  • Step 1: Write failing test

# server/tests/test_stations_api.py
"""Integration tests for /api/stations endpoints."""
import pytest
from httpx import AsyncClient

from tests.conftest import auth_headers, create_test_recipe


@pytest.mark.asyncio
async def test_list_stations_requires_auth(client: AsyncClient):
    resp = await client.get("/api/stations")
    assert resp.status_code == 401


@pytest.mark.asyncio
async def test_create_station_as_admin(client: AsyncClient, admin_user):
    resp = await client.post(
        "/api/stations",
        headers=auth_headers(admin_user),
        json={"code": "ST-API", "name": "Via API"},
    )
    assert resp.status_code == 201, resp.text
    body = resp.json()
    assert body["code"] == "ST-API"
    assert body["active"] is True
    assert body["id"] > 0


@pytest.mark.asyncio
async def test_create_station_non_admin_is_403(client: AsyncClient, maker_user):
    resp = await client.post(
        "/api/stations",
        headers=auth_headers(maker_user),
        json={"code": "ST-NO", "name": "No"},
    )
    assert resp.status_code == 403


@pytest.mark.asyncio
async def test_update_station(client: AsyncClient, admin_user):
    created = await client.post(
        "/api/stations",
        headers=auth_headers(admin_user),
        json={"code": "ST-UP", "name": "Old"},
    )
    station_id = created.json()["id"]
    resp = await client.put(
        f"/api/stations/{station_id}",
        headers=auth_headers(admin_user),
        json={"name": "New", "active": False},
    )
    assert resp.status_code == 200
    body = resp.json()
    assert body["name"] == "New"
    assert body["active"] is False


@pytest.mark.asyncio
async def test_delete_station(client: AsyncClient, admin_user):
    created = await client.post(
        "/api/stations",
        headers=auth_headers(admin_user),
        json={"code": "ST-D", "name": "D"},
    )
    sid = created.json()["id"]
    resp = await client.delete(
        f"/api/stations/{sid}", headers=auth_headers(admin_user),
    )
    assert resp.status_code == 204
    again = await client.get(
        f"/api/stations/{sid}", headers=auth_headers(admin_user),
    )
    assert again.status_code == 404


@pytest.mark.asyncio
async def test_assign_and_unassign_recipe(
    client: AsyncClient, admin_user, db_session,
):
    recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-AS")
    await db_session.commit()
    created = await client.post(
        "/api/stations",
        headers=auth_headers(admin_user),
        json={"code": "ST-ASSIGN", "name": "A"},
    )
    sid = created.json()["id"]
    a = await client.post(
        f"/api/stations/{sid}/recipes",
        headers=auth_headers(admin_user),
        json={"recipe_id": recipe.id},
    )
    assert a.status_code == 201
    r = await client.get(
        f"/api/stations/{sid}/recipes",
        headers=auth_headers(admin_user),
    )
    assert r.status_code == 200
    assert [rec["code"] for rec in r.json()] == ["REC-AS"]
    u = await client.delete(
        f"/api/stations/{sid}/recipes/{recipe.id}",
        headers=auth_headers(admin_user),
    )
    assert u.status_code == 204
    r2 = await client.get(
        f"/api/stations/{sid}/recipes",
        headers=auth_headers(admin_user),
    )
    assert r2.json() == []


@pytest.mark.asyncio
async def test_list_recipes_by_station_code(
    client: AsyncClient, admin_user, measurement_tec_user, db_session,
):
    recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-BC")
    await db_session.commit()
    created = await client.post(
        "/api/stations",
        headers=auth_headers(admin_user),
        json={"code": "ST-BC", "name": "BC"},
    )
    sid = created.json()["id"]
    await client.post(
        f"/api/stations/{sid}/recipes",
        headers=auth_headers(admin_user),
        json={"recipe_id": recipe.id},
    )
    resp = await client.get(
        "/api/stations/by-code/ST-BC/recipes",
        headers=auth_headers(measurement_tec_user),
    )
    assert resp.status_code == 200
    assert [r["code"] for r in resp.json()] == ["REC-BC"]


@pytest.mark.asyncio
async def test_list_recipes_by_unknown_code_404(
    client: AsyncClient, measurement_tec_user,
):
    resp = await client.get(
        "/api/stations/by-code/ST-DOES-NOT-EXIST/recipes",
        headers=auth_headers(measurement_tec_user),
    )
    assert resp.status_code == 404
  • Step 2: Run test to verify it fails

Run: cd server && pytest tests/test_stations_api.py -v Expected: all fail with 404 because the router isn't registered.

  • Step 3: Write the router
# server/routers/stations.py
"""Stations router - CRUD + recipe assignments."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession

from database import get_db
from middleware.api_key import get_current_user, require_admin_user
from models.user import User
from schemas.station import (
    StationCreate, StationUpdate, StationResponse,
    StationRecipeAssignmentCreate, StationRecipeAssignmentResponse,
    _RecipeSummary,
)
from services import station_service

router = APIRouter(prefix="/api/stations", tags=["stations"])


@router.get("", response_model=list[StationResponse])
async def list_stations(
    active_only: bool = False,
    admin: User = Depends(require_admin_user),
    db: AsyncSession = Depends(get_db),
):
    """List all stations (admin only)."""
    stations = await station_service.list_stations(db, active_only=active_only)
    return [StationResponse.model_validate(s) for s in stations]


@router.post("", response_model=StationResponse, status_code=status.HTTP_201_CREATED)
async def create_new_station(
    data: StationCreate,
    admin: User = Depends(require_admin_user),
    db: AsyncSession = Depends(get_db),
):
    """Create a station (admin only)."""
    station = await station_service.create_station(db, data, admin)
    return StationResponse.model_validate(station)


@router.get("/{station_id}", response_model=StationResponse)
async def get_single_station(
    station_id: int,
    admin: User = Depends(require_admin_user),
    db: AsyncSession = Depends(get_db),
):
    """Get a station by id (admin only)."""
    station = await station_service.get_station(db, station_id)
    return StationResponse.model_validate(station)


@router.put("/{station_id}", response_model=StationResponse)
async def update_existing_station(
    station_id: int,
    data: StationUpdate,
    admin: User = Depends(require_admin_user),
    db: AsyncSession = Depends(get_db),
):
    """Update a station (admin only)."""
    station = await station_service.update_station(db, station_id, data)
    return StationResponse.model_validate(station)


@router.delete("/{station_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_station(
    station_id: int,
    admin: User = Depends(require_admin_user),
    db: AsyncSession = Depends(get_db),
):
    """Delete a station (admin only). Cascades to assignments."""
    await station_service.delete_station(db, station_id)


@router.get("/{station_id}/recipes", response_model=list[_RecipeSummary])
async def list_assigned_recipes(
    station_id: int,
    admin: User = Depends(require_admin_user),
    db: AsyncSession = Depends(get_db),
):
    """Admin view: recipes assigned to this station (active only)."""
    recipes = await station_service.list_station_recipes(db, station_id)
    return [_RecipeSummary.model_validate(r) for r in recipes]


@router.post(
    "/{station_id}/recipes",
    response_model=StationRecipeAssignmentResponse,
    status_code=status.HTTP_201_CREATED,
)
async def assign_recipe_to_station(
    station_id: int,
    data: StationRecipeAssignmentCreate,
    admin: User = Depends(require_admin_user),
    db: AsyncSession = Depends(get_db),
):
    """Assign a recipe to a station (admin only)."""
    assignment = await station_service.assign_recipe(
        db, station_id, data.recipe_id, admin,
    )
    return StationRecipeAssignmentResponse.model_validate(assignment)


@router.delete(
    "/{station_id}/recipes/{recipe_id}",
    status_code=status.HTTP_204_NO_CONTENT,
)
async def unassign_recipe_from_station(
    station_id: int,
    recipe_id: int,
    admin: User = Depends(require_admin_user),
    db: AsyncSession = Depends(get_db),
):
    """Remove a recipe assignment (admin only)."""
    await station_service.unassign_recipe(db, station_id, recipe_id)


@router.get(
    "/by-code/{code}/recipes",
    response_model=list[_RecipeSummary],
)
async def list_recipes_by_station_code(
    code: str,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Operator view: active recipes assigned to the station with this code.

    Used by the Flask client at startup / on select_recipe page.
    Any authenticated user can call this; filtering is by station code from
    the client's STATION_CODE environment variable.
    """
    station = await station_service.get_station_by_code(db, code)
    if station is None or not station.active:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Station '{code}' not found or inactive",
        )
    recipes = await station_service.list_station_recipes(db, station.id)
    return [_RecipeSummary.model_validate(r) for r in recipes]
  • Step 4: Register the router in main.py

Modify server/main.py — find the section where other routers are included (e.g. app.include_router(users_router)) and add:

from routers.stations import router as stations_router
# ...
app.include_router(stations_router)

Place the import alongside the existing router imports and the include_router call alongside the others; follow local style.

  • Step 5: Run test to verify it passes

Run: cd server && pytest tests/test_stations_api.py -v Expected: 8 passed.

  • Step 6: Run full server test suite — no regressions

Run: cd server && pytest -q Expected: all previous tests still pass.

  • Step 7: Commit
git add server/routers/stations.py server/main.py server/tests/test_stations_api.py
git commit -m "feat(api): add /api/stations router with CRUD and assignments"

Task 6: Seed Default Station on Setup

Rationale: when the migration runs against an existing DB, there are already recipes but no stations — clients would break. The /api/setup endpoint (used for initial seeding) and the dev init_db() path must create a ST-DEFAULT station and auto-assign all active recipes to it, so existing installations keep working.

Files:

  • Modify: server/routers/setup.py

  • Test: server/tests/test_station_seed.py

  • Step 1: Read the current setup flow

Skim server/routers/setup.py to find the function that seeds demo data. Identify where recipes are created.

  • Step 2: Write failing test
# server/tests/test_station_seed.py
"""Verify /api/setup creates a default station with all recipes assigned."""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from models.station import Station, StationRecipeAssignment
from models.recipe import Recipe


@pytest.mark.asyncio
async def test_setup_creates_default_station(client: AsyncClient, db_session: AsyncSession):
    import os
    os.environ["SETUP_PASSWORD"] = "test-setup-pwd"

    resp = await client.post(
        "/api/setup/initialize",
        json={"password": "test-setup-pwd", "load_demo_data": True},
    )
    assert resp.status_code == 200, resp.text

    result = await db_session.execute(select(Station).where(Station.code == "ST-DEFAULT"))
    default = result.scalar_one_or_none()
    assert default is not None
    assert default.active is True

    recipes = await db_session.execute(select(Recipe).where(Recipe.active == True))
    n_recipes = len(recipes.scalars().all())

    assignments = await db_session.execute(
        select(StationRecipeAssignment).where(
            StationRecipeAssignment.station_id == default.id
        )
    )
    n_assignments = len(assignments.scalars().all())
    assert n_assignments == n_recipes
  • Step 3: Run test to verify it fails

Run: cd server && pytest tests/test_station_seed.py -v Expected: FAIL (no ST-DEFAULT exists).

  • Step 4: Modify the setup seed

In server/routers/setup.py, at the end of the demo seed function (after recipes are created and committed) add:

# Create default station and assign all active recipes to it
from models.station import Station, StationRecipeAssignment
from sqlalchemy import select

result = await db.execute(select(Station).where(Station.code == "ST-DEFAULT"))
if result.scalar_one_or_none() is None:
    default_station = Station(
        code="ST-DEFAULT",
        name="Default Station",
        location="Initial seed - change me",
        created_by=admin_user.id,
    )
    db.add(default_station)
    await db.flush()
    await db.refresh(default_station)

    recipes_result = await db.execute(select(Recipe).where(Recipe.active == True))
    for r in recipes_result.scalars().all():
        db.add(StationRecipeAssignment(
            station_id=default_station.id,
            recipe_id=r.id,
            assigned_by=admin_user.id,
        ))
    await db.flush()

Adapt variable names (db, admin_user, Recipe) to match the existing ones in setup.py.

  • Step 5: Run test to verify it passes

Run: cd server && pytest tests/test_station_seed.py -v Expected: passed.

  • Step 6: Commit
git add server/routers/setup.py server/tests/test_station_seed.py
git commit -m "feat(setup): seed ST-DEFAULT station and assign existing recipes"

Task 7: Client STATION_CODE Configuration

Files:

  • Modify: client/config.py

  • Modify: .env.example

  • Test: client/tests/test_config_station.py

  • Step 1: Write failing test

# client/tests/test_config_station.py
"""Tests that STATION_CODE is loaded from env and exposed on the client config."""
import importlib
import os


def test_station_code_read_from_env(monkeypatch):
    monkeypatch.setenv("STATION_CODE", "ST-TEST")
    import config
    importlib.reload(config)
    assert config.STATION_CODE == "ST-TEST"


def test_station_code_defaults_to_none_when_missing(monkeypatch):
    monkeypatch.delenv("STATION_CODE", raising=False)
    import config
    importlib.reload(config)
    assert config.STATION_CODE is None
  • Step 2: Run test to verify it fails

Run: cd client && pytest tests/test_config_station.py -v Expected: FAIL (no STATION_CODE attribute).

  • Step 3: Add STATION_CODE to client/config.py

Open client/config.py and, alongside the other os.getenv lines (near API_SERVER_URL), add:

# Station identity: each deployed client container sets this to the station code
# it belongs to. Empty/None means "not configured" → client will show a config
# error page on select_recipe.
STATION_CODE: str | None = os.getenv("STATION_CODE") or None
  • Step 4: Add STATION_CODE to .env.example

Open .env.example (repo root) and add, near the other client variables:

# Station code this client container belongs to (e.g. ST-001).
# Each physical tablet/PC deployment must set this unique per-station value.
# Leave empty only for a single-station all-in-one demo using ST-DEFAULT.
STATION_CODE=ST-DEFAULT
  • Step 5: Run test to verify it passes

Run: cd client && pytest tests/test_config_station.py -v Expected: 2 passed.

  • Step 6: Commit
git add client/config.py client/tests/test_config_station.py .env.example
git commit -m "feat(client): add STATION_CODE env var and config attribute"

Task 8: Client api_client Helper — get_station_recipes()

Files:

  • Modify: client/services/api_client.py

  • Test: client/tests/test_api_client_stations.py

  • Step 1: Write failing test

# client/tests/test_api_client_stations.py
"""Tests for the station-related helpers in api_client."""
from unittest.mock import patch, MagicMock

from services.api_client import APIClient


def test_get_station_recipes_calls_correct_endpoint():
    client = APIClient()
    with patch.object(client, "get") as mock_get:
        mock_get.return_value = [{"id": 1, "code": "R1", "name": "R1", "active": True}]
        result = client.get_station_recipes("ST-001", api_key="abc")
        mock_get.assert_called_once_with(
            "/api/stations/by-code/ST-001/recipes", api_key="abc",
        )
        assert result == [{"id": 1, "code": "R1", "name": "R1", "active": True}]
  • Step 2: Run test to verify it fails

Run: cd client && pytest tests/test_api_client_stations.py -v Expected: FAIL with AttributeError: 'APIClient' object has no attribute 'get_station_recipes'.

  • Step 3: Add the helper method

In client/services/api_client.py find the class APIClient and add a method (following the style of any existing helpers like get_recipe):

def get_station_recipes(self, station_code: str, api_key: str):
    """Return the list of active recipes assigned to the given station."""
    return self.get(f"/api/stations/by-code/{station_code}/recipes", api_key=api_key)
  • Step 4: Run test to verify it passes

Run: cd client && pytest tests/test_api_client_stations.py -v Expected: passed.

  • Step 5: Commit
git add client/services/api_client.py client/tests/test_api_client_stations.py
git commit -m "feat(client): add get_station_recipes helper on APIClient"

Task 9: Client measure.select_recipe — Filter by STATION_CODE

Goal: when the operator reaches the recipe-selection page, the list is filtered to only the recipes assigned to this tablet's station. If STATION_CODE is unset, show an explicit configuration error page.

Files:

  • Modify: client/blueprints/measure.py

  • Modify: client/templates/measure/select_recipe.html

  • Create: client/templates/errors/station_not_configured.html

  • Test: client/tests/test_measure_station_filter.py

  • Step 1: Write failing test

# client/tests/test_measure_station_filter.py
"""Verify that /measure/select reads STATION_CODE and filters recipes via the server."""
from unittest.mock import patch


def test_select_recipe_calls_station_endpoint(logged_in_client, monkeypatch):
    monkeypatch.setenv("STATION_CODE", "ST-TEST")
    import config; import importlib; importlib.reload(config)
    import blueprints.measure; importlib.reload(blueprints.measure)

    with patch("blueprints.measure.api_client") as mock_api:
        mock_api.get_station_recipes.return_value = [
            {"id": 1, "code": "R1", "name": "Recipe 1", "active": True},
        ]
        resp = logged_in_client.get("/measure/select")
        assert resp.status_code == 200
        mock_api.get_station_recipes.assert_called_once()
        args, kwargs = mock_api.get_station_recipes.call_args
        assert args[0] == "ST-TEST" or kwargs.get("station_code") == "ST-TEST"


def test_select_recipe_without_station_code_shows_error(logged_in_client, monkeypatch):
    monkeypatch.delenv("STATION_CODE", raising=False)
    import config; import importlib; importlib.reload(config)
    import blueprints.measure; importlib.reload(blueprints.measure)

    resp = logged_in_client.get("/measure/select")
    assert resp.status_code == 503
    assert b"STATION_CODE" in resp.data or b"stazione" in resp.data.lower()
  • Step 2: Run test to verify it fails

Run: cd client && pytest tests/test_measure_station_filter.py -v Expected: FAIL (either wrong API called or no error page).

  • Step 3: Modify select_recipe view

Open client/blueprints/measure.py. Find the select_recipe view (handles GET /measure/select). Replace the call that currently lists recipes with:

import config
from flask import render_template, session

@measure_bp.route("/select", methods=["GET"])
def select_recipe():
    if not config.STATION_CODE:
        return render_template(
            "errors/station_not_configured.html",
        ), 503
    api_key = session.get("api_key")
    try:
        recipes = api_client.get_station_recipes(config.STATION_CODE, api_key=api_key)
    except Exception as e:
        return render_template(
            "errors/station_not_configured.html",
            error=str(e),
        ), 502
    return render_template(
        "measure/select_recipe.html",
        recipes=recipes,
        station_code=config.STATION_CODE,
    )

Adapt to match the existing function signature and parameter names (query search, barcode, lot, serial). Keep all pre-existing URL params (?recipe=&lot=&serial=).

  • Step 4: Create the error template
{# client/templates/errors/station_not_configured.html #}
{% extends "base.html" %}
{% block content %}
<div class="max-w-xl mx-auto mt-20 p-8 bg-white dark:bg-steel-800 rounded-xl shadow-lg text-center">
  <h1 class="text-2xl font-bold text-measure-fail mb-4">
    {{ _('Stazione non configurata') }}
  </h1>
  <p class="mb-4">
    {{ _('Questo client non ha impostato la variabile di ambiente STATION_CODE.') }}
  </p>
  <p class="mb-4 text-sm text-steel-500">
    {{ _('Contattare il responsabile IT: il file .env del container deve contenere STATION_CODE con il codice della stazione assegnata.') }}
  </p>
  {% if error %}
  <pre class="text-xs text-steel-500 bg-steel-100 dark:bg-steel-900 p-2 rounded">{{ error }}</pre>
  {% endif %}
</div>
{% endblock %}
  • Step 5: Show station_code in the select_recipe template

In client/templates/measure/select_recipe.html add near the top, inside the header block:

<div class="text-sm text-steel-500 dark:text-steel-400 mb-2">
  {{ _('Stazione') }}: <span class="font-mono font-bold">{{ station_code }}</span>
</div>

Leave the rest of the template intact.

  • Step 6: Run test to verify it passes

Run: cd client && pytest tests/test_measure_station_filter.py -v Expected: 2 passed.

  • Step 7: Full client test suite — no regressions

Run: cd client && pytest -q Expected: all previous tests still pass.

  • Step 8: Commit
git add client/blueprints/measure.py client/templates/measure/select_recipe.html client/templates/errors/station_not_configured.html client/tests/test_measure_station_filter.py
git commit -m "feat(client): filter select_recipe by STATION_CODE with error fallback"

Task 10: Admin Blueprint Routes for Stations

Files:

  • Modify: client/blueprints/admin.py

  • Test: client/tests/test_admin_stations.py

  • Step 1: Write failing test

# client/tests/test_admin_stations.py
"""Tests that the admin blueprint exposes /admin/stations CRUD routes."""
from unittest.mock import patch


def test_admin_stations_page_renders(logged_in_admin_client):
    with patch("blueprints.admin.APIClient") as MockClient:
        MockClient.return_value.get.return_value = [
            {"id": 1, "code": "ST-1", "name": "One", "active": True,
             "location": None, "notes": None, "created_by": 1,
             "created_at": "2026-04-17T00:00:00"},
        ]
        resp = logged_in_admin_client.get("/admin/stations")
        assert resp.status_code == 200
        assert b"ST-1" in resp.data


def test_admin_create_station_posts_to_server(logged_in_admin_client):
    with patch("blueprints.admin.APIClient") as MockClient:
        MockClient.return_value.post.return_value = {
            "id": 5, "code": "ST-NEW", "name": "N", "active": True,
            "location": None, "notes": None, "created_by": 1,
            "created_at": "2026-04-17T00:00:00",
        }
        resp = logged_in_admin_client.post(
            "/admin/api/stations",
            json={"code": "ST-NEW", "name": "N"},
        )
        assert resp.status_code == 201
        MockClient.return_value.post.assert_called_once()


def test_admin_assign_recipe_to_station(logged_in_admin_client):
    with patch("blueprints.admin.APIClient") as MockClient:
        MockClient.return_value.post.return_value = {
            "id": 10, "station_id": 1, "recipe_id": 7,
            "assigned_by": 1, "assigned_at": "2026-04-17T00:00:00",
        }
        resp = logged_in_admin_client.post(
            "/admin/api/stations/1/recipes",
            json={"recipe_id": 7},
        )
        assert resp.status_code == 201

The fixture logged_in_admin_client is a session with is_admin=True; add it to client/tests/conftest.py if not present — see step 2.

  • Step 2: Add fixture logged_in_admin_client in client/tests/conftest.py

If the file already has logged_in_client but not logged_in_admin_client, add (near logged_in_client):

@pytest.fixture
def logged_in_admin_client(client):
    with client.session_transaction() as sess:
        sess["api_key"] = "admin-test-key"
        sess["user_id"] = 1
        sess["language"] = "en"
        sess["theme"] = "light"
        sess["user"] = {
            "id": 1, "username": "admin", "display_name": "Admin",
            "roles": ["Maker", "MeasurementTec", "Metrologist"],
            "is_admin": True, "language_pref": "en", "theme_pref": "light",
            "active": True, "email": None,
        }
    return client
  • Step 3: Run test to verify it fails

Run: cd client && pytest tests/test_admin_stations.py -v Expected: FAIL (routes don't exist).

  • Step 4: Add the admin routes

Open client/blueprints/admin.py. Add, following the style of the existing user-management routes:

from flask import jsonify, render_template, request

@admin_bp.route("/stations", methods=["GET"])
def stations_page():
    api_key = session.get("api_key")
    client = APIClient()
    try:
        stations = client.get("/api/stations", api_key=api_key)
    except Exception:
        stations = []
    return render_template("admin/stations.html", stations=stations)


@admin_bp.route("/stations/<int:station_id>", methods=["GET"])
def station_detail_page(station_id: int):
    api_key = session.get("api_key")
    client = APIClient()
    try:
        station = client.get(f"/api/stations/{station_id}", api_key=api_key)
        recipes = client.get(f"/api/stations/{station_id}/recipes", api_key=api_key)
        all_recipes = client.get("/api/recipes", api_key=api_key)
    except Exception:
        station, recipes, all_recipes = None, [], []
    return render_template(
        "admin/station_detail.html",
        station=station, assigned_recipes=recipes, all_recipes=all_recipes,
    )


@admin_bp.route("/api/stations", methods=["POST"])
def api_create_station():
    api_key = session.get("api_key")
    client = APIClient()
    data = request.get_json(silent=True) or {}
    try:
        created = client.post("/api/stations", data=data, api_key=api_key)
        return jsonify(created), 201
    except Exception as e:
        return jsonify({"error": True, "detail": str(e)}), 500


@admin_bp.route("/api/stations/<int:station_id>", methods=["PUT"])
def api_update_station(station_id: int):
    api_key = session.get("api_key")
    client = APIClient()
    data = request.get_json(silent=True) or {}
    try:
        updated = client.put(f"/api/stations/{station_id}", data=data, api_key=api_key)
        return jsonify(updated), 200
    except Exception as e:
        return jsonify({"error": True, "detail": str(e)}), 500


@admin_bp.route("/api/stations/<int:station_id>", methods=["DELETE"])
def api_delete_station(station_id: int):
    api_key = session.get("api_key")
    client = APIClient()
    try:
        client.delete(f"/api/stations/{station_id}", api_key=api_key)
        return "", 204
    except Exception as e:
        return jsonify({"error": True, "detail": str(e)}), 500


@admin_bp.route("/api/stations/<int:station_id>/recipes", methods=["POST"])
def api_assign_recipe(station_id: int):
    api_key = session.get("api_key")
    client = APIClient()
    data = request.get_json(silent=True) or {}
    try:
        created = client.post(
            f"/api/stations/{station_id}/recipes", data=data, api_key=api_key,
        )
        return jsonify(created), 201
    except Exception as e:
        return jsonify({"error": True, "detail": str(e)}), 500


@admin_bp.route("/api/stations/<int:station_id>/recipes/<int:recipe_id>", methods=["DELETE"])
def api_unassign_recipe(station_id: int, recipe_id: int):
    api_key = session.get("api_key")
    client = APIClient()
    try:
        client.delete(
            f"/api/stations/{station_id}/recipes/{recipe_id}", api_key=api_key,
        )
        return "", 204
    except Exception as e:
        return jsonify({"error": True, "detail": str(e)}), 500

Adapt session, APIClient import, and error-normalization helpers to match the existing style in the same file.

  • Step 5: Run test to verify it passes

Run: cd client && pytest tests/test_admin_stations.py -v Expected: 3 passed.

  • Step 6: Commit
git add client/blueprints/admin.py client/tests/test_admin_stations.py client/tests/conftest.py
git commit -m "feat(client): add admin station CRUD routes and recipe assignment proxy"

Task 11: Admin Template — Stations List Page

Files:

  • Create: client/templates/admin/stations.html

  • Step 1: Write the template

{# client/templates/admin/stations.html #}
{% extends "base.html" %}
{% block content %}
<div class="max-w-6xl mx-auto p-6"
     x-data="stationsPage({{ stations | tojson_attr }})">
  <div class="flex items-center justify-between mb-6">
    <h1 class="text-2xl font-bold">{{ _('Gestione Stazioni') }}</h1>
    <button @click="openCreateModal()"
            class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-700">
      {{ _('Nuova Stazione') }}
    </button>
  </div>

  <div class="bg-white dark:bg-steel-800 rounded-xl shadow overflow-hidden">
    <table class="min-w-full">
      <thead class="bg-steel-50 dark:bg-steel-900">
        <tr class="text-left text-sm text-steel-600 dark:text-steel-300">
          <th class="px-4 py-3">{{ _('Codice') }}</th>
          <th class="px-4 py-3">{{ _('Nome') }}</th>
          <th class="px-4 py-3">{{ _('Ubicazione') }}</th>
          <th class="px-4 py-3">{{ _('Stato') }}</th>
          <th class="px-4 py-3 text-right">{{ _('Azioni') }}</th>
        </tr>
      </thead>
      <tbody>
        <template x-for="s in stations" :key="s.id">
          <tr class="border-t border-steel-200 dark:border-steel-700">
            <td class="px-4 py-3 font-mono" x-text="s.code"></td>
            <td class="px-4 py-3" x-text="s.name"></td>
            <td class="px-4 py-3 text-sm text-steel-500"
                x-text="s.location || '—'"></td>
            <td class="px-4 py-3">
              <span x-show="s.active"
                    class="px-2 py-1 text-xs rounded-full bg-measure-pass/20 text-measure-pass">
                {{ _('Attiva') }}
              </span>
              <span x-show="!s.active"
                    class="px-2 py-1 text-xs rounded-full bg-measure-fail/20 text-measure-fail">
                {{ _('Disattivata') }}
              </span>
            </td>
            <td class="px-4 py-3 text-right space-x-2">
              <a :href="`/admin/stations/${s.id}`"
                 class="text-primary hover:underline">{{ _('Dettagli') }}</a>
              <button @click="deleteStation(s)"
                      class="text-measure-fail hover:underline">
                {{ _('Elimina') }}
              </button>
            </td>
          </tr>
        </template>
        <tr x-show="stations.length === 0">
          <td colspan="5" class="px-4 py-8 text-center text-steel-500">
            {{ _('Nessuna stazione configurata.') }}
          </td>
        </tr>
      </tbody>
    </table>
  </div>

  {# Create modal #}
  <div x-show="createModalOpen" x-cloak
       class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
    <div @click.outside="createModalOpen = false"
         class="bg-white dark:bg-steel-800 rounded-xl p-6 w-full max-w-md">
      <h2 class="text-xl font-bold mb-4">{{ _('Nuova Stazione') }}</h2>
      <form @submit.prevent="createStation()" class="space-y-4">
        <div>
          <label class="block text-sm mb-1">{{ _('Codice') }}</label>
          <input x-model="newStation.code" required
                 class="w-full px-3 py-2 border rounded-lg font-mono">
        </div>
        <div>
          <label class="block text-sm mb-1">{{ _('Nome') }}</label>
          <input x-model="newStation.name" required
                 class="w-full px-3 py-2 border rounded-lg">
        </div>
        <div>
          <label class="block text-sm mb-1">{{ _('Ubicazione') }}</label>
          <input x-model="newStation.location"
                 class="w-full px-3 py-2 border rounded-lg">
        </div>
        <div class="flex justify-end space-x-2">
          <button type="button" @click="createModalOpen = false"
                  class="px-4 py-2 bg-steel-200 dark:bg-steel-700 rounded-lg">
            {{ _('Annulla') }}
          </button>
          <button type="submit"
                  class="px-4 py-2 bg-primary text-white rounded-lg">
            {{ _('Crea') }}
          </button>
        </div>
      </form>
    </div>
  </div>
</div>
{% endblock %}

{% block extra_js %}
<script>
function stationsPage(initialStations) {
  return {
    stations: initialStations || [],
    createModalOpen: false,
    newStation: { code: '', name: '', location: '' },
    openCreateModal() {
      this.newStation = { code: '', name: '', location: '' };
      this.createModalOpen = true;
    },
    async createStation() {
      const resp = await fetch('/admin/api/stations', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(this.newStation),
      });
      if (resp.ok) {
        const created = await resp.json();
        this.stations.push(created);
        this.createModalOpen = false;
      } else {
        alert('{{ _("Errore nella creazione") }}');
      }
    },
    async deleteStation(s) {
      if (!confirm('{{ _("Eliminare la stazione?") }} ' + s.code)) return;
      const resp = await fetch(`/admin/api/stations/${s.id}`, { method: 'DELETE' });
      if (resp.ok) {
        this.stations = this.stations.filter(x => x.id !== s.id);
      }
    },
  };
}
</script>
{% endblock %}
  • Step 2: Manual smoke test

Start client + server, login as admin, navigate to /admin/stations. Expected: the page renders, the "Nuova Stazione" modal creates a new station, delete removes it.

  • Step 3: Commit
git add client/templates/admin/stations.html
git commit -m "feat(client): add admin stations list and create modal"

Task 12: Admin Template — Station Detail with Recipe Assignment

Files:

  • Create: client/templates/admin/station_detail.html

  • Step 1: Write the template

{# client/templates/admin/station_detail.html #}
{% extends "base.html" %}
{% block content %}
<div class="max-w-5xl mx-auto p-6"
     x-data="stationDetail(
       {{ station | tojson_attr }},
       {{ assigned_recipes | tojson_attr }},
       {{ all_recipes | tojson_attr }}
     )">
  <a href="/admin/stations" class="text-sm text-primary hover:underline">
    ← {{ _('Tutte le stazioni') }}
  </a>

  <h1 class="text-2xl font-bold mt-2 mb-6">
    <span class="font-mono" x-text="station.code"></span><span x-text="station.name"></span>
  </h1>

  <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
    {# Assigned recipes #}
    <div class="bg-white dark:bg-steel-800 rounded-xl shadow p-4">
      <h2 class="font-bold mb-4">{{ _('Ricette assegnate') }}</h2>
      <ul class="divide-y divide-steel-200 dark:divide-steel-700">
        <template x-for="r in assigned" :key="r.id">
          <li class="py-2 flex items-center justify-between">
            <div>
              <span class="font-mono text-sm" x-text="r.code"></span>
              <span class="text-steel-500 ml-2" x-text="r.name"></span>
            </div>
            <button @click="unassign(r)"
                    class="text-measure-fail text-sm hover:underline">
              {{ _('Rimuovi') }}
            </button>
          </li>
        </template>
        <li x-show="assigned.length === 0" class="py-4 text-center text-steel-500">
          {{ _('Nessuna ricetta assegnata.') }}
        </li>
      </ul>
    </div>

    {# Available recipes to assign #}
    <div class="bg-white dark:bg-steel-800 rounded-xl shadow p-4">
      <h2 class="font-bold mb-4">{{ _('Ricette disponibili') }}</h2>
      <input x-model="search"
             :placeholder="'{{ _(\"Filtra per codice/nome\") }}'"
             class="w-full px-3 py-2 mb-3 border rounded-lg">
      <ul class="divide-y divide-steel-200 dark:divide-steel-700 max-h-96 overflow-y-auto">
        <template x-for="r in filteredAvailable()" :key="r.id">
          <li class="py-2 flex items-center justify-between">
            <div>
              <span class="font-mono text-sm" x-text="r.code"></span>
              <span class="text-steel-500 ml-2" x-text="r.name"></span>
            </div>
            <button @click="assign(r)"
                    class="text-primary text-sm hover:underline">
              {{ _('Assegna') }}
            </button>
          </li>
        </template>
      </ul>
    </div>
  </div>
</div>
{% endblock %}

{% block extra_js %}
<script>
function stationDetail(station, assignedInit, allRecipes) {
  return {
    station: station,
    assigned: assignedInit || [],
    all: allRecipes || [],
    search: '',
    filteredAvailable() {
      const ids = new Set(this.assigned.map(r => r.id));
      const q = this.search.toLowerCase();
      return this.all
        .filter(r => !ids.has(r.id))
        .filter(r => !q || r.code.toLowerCase().includes(q)
                       || r.name.toLowerCase().includes(q));
    },
    async assign(r) {
      const resp = await fetch(`/admin/api/stations/${this.station.id}/recipes`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ recipe_id: r.id }),
      });
      if (resp.ok) this.assigned.push(r);
    },
    async unassign(r) {
      const resp = await fetch(
        `/admin/api/stations/${this.station.id}/recipes/${r.id}`,
        { method: 'DELETE' },
      );
      if (resp.ok) this.assigned = this.assigned.filter(x => x.id !== r.id);
    },
  };
}
</script>
{% endblock %}
  • Step 2: Manual smoke test

Navigate to /admin/stations/<id> as admin. Expected: two panels, drag-less assign/unassign works, filter filters.

  • Step 3: Commit
git add client/templates/admin/station_detail.html
git commit -m "feat(client): add admin station detail with recipe assignment UI"

Files:

  • Modify: client/templates/components/navbar.html

  • Step 1: Find the admin section of the navbar

Open client/templates/components/navbar.html. Find the block that renders links visible only to is_admin users (there's probably already a "Utenti" / "Users" link).

  • Step 2: Add the Stations link

Next to the existing admin links, add:

{% if current_user.is_admin %}
<a href="/admin/stations"
   class="px-3 py-2 hover:bg-steel-100 dark:hover:bg-steel-700 rounded-lg">
  {{ _('Stazioni') }}
</a>
{% endif %}

Adapt the conditional syntax (current_user.is_admin vs session.user.is_admin) to match the existing navbar pattern.

  • Step 3: Manual smoke test

Login as admin → verify navbar shows "Stazioni" link, clicking navigates to /admin/stations.

  • Step 4: Commit
git add client/templates/components/navbar.html
git commit -m "feat(client): add Stazioni link in admin navbar"

Task 14: i18n Catalog Update

Files:

  • Modify: client/translations/messages.pot, client/translations/it/LC_MESSAGES/messages.po, client/translations/en/LC_MESSAGES/messages.po

  • Run: pybabel extract + pybabel update + pybabel compile

  • Step 1: Extract strings

cd client && pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
  • Step 2: Update per-locale .po files
cd client && pybabel update -i translations/messages.pot -d translations
  • Step 3: Translate new strings

Open client/translations/it/LC_MESSAGES/messages.po and client/translations/en/LC_MESSAGES/messages.po. Fill in translations for newly extracted strings added in Tasks 9, 11, 12, 13 (e.g. Gestione Stazioni, Nuova Stazione, Stazione non configurata, Ricette assegnate, etc.).

  • Step 4: Compile
cd client && pybabel compile -d translations
  • Step 5: Manual smoke test — language switch

Switch language between IT and EN in the UI; verify Stations pages translate correctly.

  • Step 6: Commit
git add client/translations/
git commit -m "i18n: add translations for stations management UI"

Task 15: End-to-End Integration Test

Files:

  • Create: server/tests/test_stations_e2e.py

  • Step 1: Write the E2E scenario

# server/tests/test_stations_e2e.py
"""End-to-end: two stations see only their own assigned recipes."""
import pytest
from httpx import AsyncClient

from tests.conftest import auth_headers, create_test_recipe


@pytest.mark.asyncio
async def test_two_stations_see_only_their_recipes(
    client: AsyncClient, admin_user, measurement_tec_user, db_session,
):
    r_a = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-A")
    r_b = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-B")
    r_shared = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-S")
    await db_session.commit()

    for code, name in [("ST-A", "Alfa"), ("ST-B", "Beta")]:
        resp = await client.post(
            "/api/stations",
            headers=auth_headers(admin_user),
            json={"code": code, "name": name},
        )
        assert resp.status_code == 201

    list_resp = await client.get(
        "/api/stations", headers=auth_headers(admin_user),
    )
    by_code = {s["code"]: s for s in list_resp.json()}

    await client.post(
        f"/api/stations/{by_code['ST-A']['id']}/recipes",
        headers=auth_headers(admin_user),
        json={"recipe_id": r_a.id},
    )
    await client.post(
        f"/api/stations/{by_code['ST-A']['id']}/recipes",
        headers=auth_headers(admin_user),
        json={"recipe_id": r_shared.id},
    )
    await client.post(
        f"/api/stations/{by_code['ST-B']['id']}/recipes",
        headers=auth_headers(admin_user),
        json={"recipe_id": r_b.id},
    )
    await client.post(
        f"/api/stations/{by_code['ST-B']['id']}/recipes",
        headers=auth_headers(admin_user),
        json={"recipe_id": r_shared.id},
    )

    ra = await client.get(
        "/api/stations/by-code/ST-A/recipes",
        headers=auth_headers(measurement_tec_user),
    )
    rb = await client.get(
        "/api/stations/by-code/ST-B/recipes",
        headers=auth_headers(measurement_tec_user),
    )
    assert ra.status_code == 200 and rb.status_code == 200
    codes_a = {r["code"] for r in ra.json()}
    codes_b = {r["code"] for r in rb.json()}
    assert codes_a == {"REC-A", "REC-S"}
    assert codes_b == {"REC-B", "REC-S"}
    assert "REC-B" not in codes_a
    assert "REC-A" not in codes_b
  • Step 2: Run the test

Run: cd server && pytest tests/test_stations_e2e.py -v Expected: passed.

  • Step 3: Run full server suite

Run: cd server && pytest -q Expected: all tests pass.

  • Step 4: Commit
git add server/tests/test_stations_e2e.py
git commit -m "test(e2e): two stations see only assigned recipes"

Task 16: Apply Migration Against Dev DB and Verify

Files: none (operational)

  • Step 1: Apply the migration
cd server && alembic -c migrations/alembic.ini upgrade head

Expected: migration 002_add_stations applied without errors. Check MySQL:

SHOW TABLES LIKE 'station%';
-- expected: stations, station_recipe_assignments
  • Step 2: Re-run the demo setup to trigger the seed

With SETUP_PASSWORD set in .env, call POST /api/setup/initialize (via Swagger or curl). Expected response status 200.

Verify:

SELECT code, name, active FROM stations;
-- expected: one row with ST-DEFAULT
SELECT COUNT(*) FROM station_recipe_assignments WHERE station_id = (SELECT id FROM stations WHERE code = 'ST-DEFAULT');
-- expected: equal to COUNT(*) FROM recipes WHERE active = 1
  • Step 3: Smoke test the client flow
  1. Start dev stack: docker compose -f docker-compose.dev.yml up -d (assumes STATION_CODE=ST-DEFAULT in client .env).
  2. Open browser → login as MeasurementTec → /measure/select.
  3. Expected: recipes list, "Stazione: ST-DEFAULT" header visible.
  4. Remove STATION_CODE from .env, restart client.
  5. Expected: error page "Stazione non configurata" with 503.
  • Step 4: Create a pilot station in admin UI
  1. Login as admin → /admin/stations → Nuova Stazione → ST-PILOT, Pilot.
  2. Click Dettagli → assign 1 recipe from the available list.
  3. Change STATION_CODE=ST-PILOT in client .env, restart client.
  4. Expected: select_recipe shows only that 1 recipe.
  • Step 5: Downgrade check (rollback safety)

In a scratch environment:

cd server && alembic -c migrations/alembic.ini downgrade -1

Expected: both tables dropped without error.

Then re-upgrade:

cd server && alembic -c migrations/alembic.ini upgrade head
  • Step 6: No commit required (operational task).

Task 17: Documentation Update

Files:

  • Modify: CLAUDE.md, README.md, docs/API.md, docs/DEPLOYMENT.md

  • Step 1: Update CLAUDE.md

In the "Configurazione" section, add to the client env vars list:

- Client: CLIENT_HOST, CLIENT_PORT, CLIENT_SECRET_KEY, API_SERVER_URL, **STATION_CODE**

Add a new section right after "Client (Flask)" explaining the Station concept:

### Station Identity
Il client Flask identifica la propria stazione fisica tramite `STATION_CODE` nel `.env`.
All'avvio di `/measure/select` chiama `GET /api/stations/by-code/{code}/recipes` per ottenere
solo le ricette assegnate alla propria stazione. Se `STATION_CODE` manca, la pagina mostra
un errore 503 "Stazione non configurata".

Le stazioni e le assegnazioni ricetta↔stazione sono gestite dagli admin in `/admin/stations`.
Il seed iniziale crea una stazione `ST-DEFAULT` con tutte le ricette assegnate.
  • Step 2: Update README.md

Aggiungere una frase nel paragrafo "Configurazione" o equivalente indicando la nuova variabile STATION_CODE necessaria per il client.

  • Step 3: Update docs/API.md

Aggiungere la sezione "Stations" con gli endpoint di Task 5 e i relativi schemi.

  • Step 4: Update docs/DEPLOYMENT.md

Nel paragrafo di configurazione client, chiarire che ogni container/tablet deve avere il suo STATION_CODE univoco.

  • Step 5: Commit
git add CLAUDE.md README.md docs/API.md docs/DEPLOYMENT.md
git commit -m "docs: describe Station identity and admin management"

Definition of Done (tutta la Fase 1)

  • Tutti i test nuovi e pre-esistenti passano in server e client (pytest -q pulito).
  • Migration 002_add_stations applicata e downgrade testato.
  • Seed ST-DEFAULT crea associazioni con tutte le ricette attive esistenti.
  • Admin può creare/modificare/eliminare stazioni in /admin/stations.
  • Admin può assegnare/rimuovere ricette ad ogni stazione in /admin/stations/<id>.
  • Client con STATION_CODE=ST-A vede solo le ricette di ST-A; con STATION_CODE=ST-B solo quelle di ST-B.
  • Client senza STATION_CODE mostra pagina di errore chiara.
  • Stringhe nuove tradotte IT + EN, catalogo compilato.
  • Documentazione (CLAUDE.md, README.md, API.md, DEPLOYMENT.md) aggiornata.

Note per l'esecutore

  1. Ordine obbligato: Task 1 → 2 → 3 → 4 → 5 → 6 sono sequenziali (ogni task dipende dal precedente). Task 7-13 (lato client) possono essere svolti dopo Task 5. Task 14-17 a fine fase.
  2. Regole commit: un commit per task completato, mai amend, formato feat(...) / test(...) / docs(...) / i18n(...). Se un hook pre-commit fallisce, analizza e correggi, non bypassare con --no-verify.
  3. Envelope response: il progetto V1.0.7 non usa l'envelope {success,data,error}. Restiamo coerenti col pattern esistente (ritorno diretto) per tutti gli endpoint di questa fase; l'eventuale migrazione a envelope è rinviata a un task di refactor globale.
  4. Tablet identity header opzionale (M2): per ora non aggiungiamo un header X-Station-Id universale; il filtraggio è esplicito per path (/by-code/...). L'header sarà introdotto in M2 se necessario per middleware di audit.
  5. Performance: list_station_recipes fa una join di due tabelle piccole con indici sulle FK, nessun problema attualmente.
  6. Retrocompatibilità: i tuoi endpoint /api/recipes originali continuano a funzionare inalterati; i client legacy non rotti.