From abd04d633cc134bfb601053de8db66483ae82fb9 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 21:17:14 +0200 Subject: [PATCH 01/14] feat(db): add migration 002 for stations and assignments --- .../migrations/versions/002_add_stations.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 server/migrations/versions/002_add_stations.py diff --git a/server/migrations/versions/002_add_stations.py b/server/migrations/versions/002_add_stations.py new file mode 100644 index 0000000..11f7480 --- /dev/null +++ b/server/migrations/versions/002_add_stations.py @@ -0,0 +1,54 @@ +"""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') From fa5d641238bd65f631ac35890bf7e2a5816efff9 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 21:30:27 +0200 Subject: [PATCH 02/14] chore: track alembic migrations in git Le migrations Alembic sono essenziali per il deploy riproducibile: rimuove la regola in .gitignore che le escludeva e aggiunge al tracking la migration 001 (image_path) gia esistente ma mai committata. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 +-- ...01_add_image_path_to_recipe_and_subtask.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 server/migrations/versions/001_add_image_path_to_recipe_and_subtask.py diff --git a/.gitignore b/.gitignore index eb3863b..b9461f3 100644 --- a/.gitignore +++ b/.gitignore @@ -52,9 +52,7 @@ node_modules/ # Flask-Babel compiled *.mo -# Alembic -server/migrations/versions/*.py -!server/migrations/versions/.gitkeep +# Alembic migrations are versioned in git (only __pycache__ is ignored, covered globally) # Logs *.log diff --git a/server/migrations/versions/001_add_image_path_to_recipe_and_subtask.py b/server/migrations/versions/001_add_image_path_to_recipe_and_subtask.py new file mode 100644 index 0000000..c1a5d39 --- /dev/null +++ b/server/migrations/versions/001_add_image_path_to_recipe_and_subtask.py @@ -0,0 +1,27 @@ +"""add image_path to recipe and subtask + +Revision ID: 001_image_path +Revises: +Create Date: 2026-02-20 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '001_image_path' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('recipes', sa.Column('image_path', sa.String(500), nullable=True)) + op.add_column('recipe_subtasks', sa.Column('image_path', sa.String(500), nullable=True)) + + +def downgrade() -> None: + op.drop_column('recipe_subtasks', 'image_path') + op.drop_column('recipes', 'image_path') From 2963d3d64784cafaaa0dd1093babe61f3450782c Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 21:40:15 +0200 Subject: [PATCH 03/14] fix(db): portability and dedup in migration 002 - server_default='1' anziche sa.true() per compatibilita con SQLite (usato come DB in-memory nei test) - Rimuove Index ix_stations_code ridondante con UniqueConstraint uq_stations_code (InnoDB crea gia un indice per i vincoli UNIQUE) Feedback da code-reviewer su Task 1. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/migrations/versions/002_add_stations.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/migrations/versions/002_add_stations.py b/server/migrations/versions/002_add_stations.py index 11f7480..e3d6edd 100644 --- a/server/migrations/versions/002_add_stations.py +++ b/server/migrations/versions/002_add_stations.py @@ -24,11 +24,10 @@ def upgrade() -> None: 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('active', sa.Boolean, nullable=False, server_default='1'), 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', From 5959c9c92a5398057d93938c9ebf12775dfb6d5a Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 21:56:22 +0200 Subject: [PATCH 04/14] feat(models): add Station and StationRecipeAssignment models TDD: test written first, confirmed failing with ModuleNotFoundError, then model implemented; all 3 new tests pass. conftest updated to import new models so Base.metadata.create_all picks up the tables. Co-Authored-By: Claude Sonnet 4.6 --- server/models/station.py | 69 ++++++++++++++++++++++++++++++ server/tests/conftest.py | 1 + server/tests/test_station_model.py | 55 ++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 server/models/station.py create mode 100644 server/tests/test_station_model.py diff --git a/server/models/station.py b/server/models/station.py new file mode 100644 index 0000000..5488d99 --- /dev/null +++ b/server/models/station.py @@ -0,0 +1,69 @@ +"""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, 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"" + + +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"" diff --git a/server/tests/conftest.py b/server/tests/conftest.py index b424c9f..abe4d79 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -39,6 +39,7 @@ from models.task import RecipeTask, RecipeSubtask from models.measurement import Measurement from models.access_log import AccessLog from models.setting import SystemSetting, RecipeVersionAudit +from models.station import Station, StationRecipeAssignment from services.auth_service import hash_password, generate_api_key # --------------------------------------------------------------------------- diff --git a/server/tests/test_station_model.py b/server/tests/test_station_model.py new file mode 100644 index 0000000..cb5daf6 --- /dev/null +++ b/server/tests/test_station_model.py @@ -0,0 +1,55 @@ +"""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 + + +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 + + +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() + await db_session.rollback() + + +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 From e36bbbb7d7ab2979bccac4ceb49e99cbec36b563 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 22:22:14 +0200 Subject: [PATCH 05/14] fix(models): align Station unique constraint + extend tests - Station.code: usa UniqueConstraint("code", name="uq_stations_code") esplicito in __table_args__ invece di unique=True sulla colonna, per allineamento con la migration 002 ed evitare drift Alembic. - Aggiunge test test_duplicate_assignment_is_rejected per coprire il vincolo uq_station_recipe (regola business centrale del modello). - Sposta import IntegrityError a module-level per consistenza. Feedback da code-reviewer su Task 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/models/station.py | 3 ++- server/tests/test_station_model.py | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/server/models/station.py b/server/models/station.py index 5488d99..b8b6a33 100644 --- a/server/models/station.py +++ b/server/models/station.py @@ -20,7 +20,7 @@ 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) + code: Mapped[str] = mapped_column(String(100), 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) @@ -35,6 +35,7 @@ class Station(Base): ) __table_args__ = ( + UniqueConstraint("code", name="uq_stations_code"), {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, ) diff --git a/server/tests/test_station_model.py b/server/tests/test_station_model.py index cb5daf6..5935bac 100644 --- a/server/tests/test_station_model.py +++ b/server/tests/test_station_model.py @@ -1,6 +1,7 @@ """Test the Station and StationRecipeAssignment ORM models.""" import pytest from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from models.station import Station, StationRecipeAssignment @@ -24,7 +25,6 @@ async def test_create_station(db_session: AsyncSession): 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() @@ -53,3 +53,21 @@ async def test_assign_recipe_to_station(db_session: AsyncSession): assignments = result.scalars().all() assert len(assignments) == 1 assert assignments[0].recipe_id == recipe.id + + +async def test_duplicate_assignment_is_rejected(db_session: AsyncSession): + admin = await _create_user(db_session, username="admin4", is_admin=True) + station = Station(code="ST-003", name="Linea 3", 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-Y") + db_session.add(StationRecipeAssignment( + station_id=station.id, recipe_id=recipe.id, assigned_by=admin.id, + )) + await db_session.flush() + db_session.add(StationRecipeAssignment( + station_id=station.id, recipe_id=recipe.id, assigned_by=admin.id, + )) + with pytest.raises(IntegrityError): + await db_session.flush() + await db_session.rollback() From 559e740d641b7d46a14cf61f2c76176dd22b1af2 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 22:25:42 +0200 Subject: [PATCH 06/14] feat(schemas): add Station and assignment Pydantic schemas --- server/schemas/station.py | 57 ++++++++++++++++++++++++++++ server/tests/test_station_schemas.py | 35 +++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 server/schemas/station.py create mode 100644 server/tests/test_station_schemas.py diff --git a/server/schemas/station.py b/server/schemas/station.py new file mode 100644 index 0000000..d44e870 --- /dev/null +++ b/server/schemas/station.py @@ -0,0 +1,57 @@ +"""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) diff --git a/server/tests/test_station_schemas.py b/server/tests/test_station_schemas.py new file mode 100644 index 0000000..5b75763 --- /dev/null +++ b/server/tests/test_station_schemas.py @@ -0,0 +1,35 @@ +"""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 From e8cd1f05aa8348337049d6f02d4c0daadc89e29c Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 22:31:16 +0200 Subject: [PATCH 07/14] 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() == [] From 338f21fba093054dd7e161a5eac524df47d89c67 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 22:37:16 +0200 Subject: [PATCH 08/14] feat(api): add /api/stations router with CRUD and assignments Implements the /api/stations FastAPI router (admin-only CRUD, recipe assignment endpoints) and the public /by-code/{code}/recipes operator endpoint. Registers the router in main.py and adds 8 integration tests. --- server/main.py | 2 + server/routers/stations.py | 138 ++++++++++++++++++++++++++++++ server/tests/test_stations_api.py | 136 +++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 server/routers/stations.py create mode 100644 server/tests/test_stations_api.py diff --git a/server/main.py b/server/main.py index 9489a9d..2028b51 100644 --- a/server/main.py +++ b/server/main.py @@ -20,6 +20,7 @@ from routers.settings import router as settings_router from routers.reports import router as reports_router from routers.statistics import router as statistics_router from routers.setup import router as setup_router +from routers.stations import router as stations_router @asynccontextmanager @@ -71,6 +72,7 @@ app.include_router(settings_router) app.include_router(statistics_router) app.include_router(reports_router) app.include_router(setup_router) +app.include_router(stations_router) @app.get("/api/health") diff --git a/server/routers/stations.py b/server/routers/stations.py new file mode 100644 index 0000000..5920610 --- /dev/null +++ b/server/routers/stations.py @@ -0,0 +1,138 @@ +"""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("/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] + + +@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) diff --git a/server/tests/test_stations_api.py b/server/tests/test_stations_api.py new file mode 100644 index 0000000..31058a1 --- /dev/null +++ b/server/tests/test_stations_api.py @@ -0,0 +1,136 @@ +"""Integration tests for /api/stations endpoints.""" +import pytest +from httpx import AsyncClient + +from tests.conftest import auth_headers, create_test_recipe + + +async def test_list_stations_requires_auth(client: AsyncClient): + resp = await client.get("/api/stations") + assert resp.status_code == 401 + + +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 + + +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 + + +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 + + +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 + + +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() == [] + + +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"] + + +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 From a79ab37addb2cd97d631ebbcad51433c06e24566 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 22:46:54 +0200 Subject: [PATCH 09/14] fix(api): improve Station router - review feedback - Rinomina _RecipeSummary -> RecipeSummary: il leading underscore segnalava "privato" ma la classe e usata come response_model pubblico ed esposta nell'OpenAPI schema. - Aggiunge commento esplicativo sopra /by-code/{code}/recipes sul perche l'ordine di dichiarazione conta (protezione gia data dal tipo int di station_id, ma esplicito per prevenire regressioni durante refactor). - Detail message del 404 by-code uniformato a "Station not found" (senza distinguere not-found vs inactive, evita leak di esistenza). - Aggiunge 3 test mancanti sul router: * test_admin_can_list_stations (copertura happy path + active_only) * test_assign_recipe_not_found_returns_404 * test_duplicate_assignment_returns_409 Feedback da code-reviewer su Task 5. Full suite: 11/11 passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/routers/stations.py | 16 +++++--- server/schemas/station.py | 4 +- server/tests/test_stations_api.py | 67 +++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/server/routers/stations.py b/server/routers/stations.py index 5920610..f44da27 100644 --- a/server/routers/stations.py +++ b/server/routers/stations.py @@ -11,7 +11,7 @@ from schemas.station import ( StationResponse, StationRecipeAssignmentCreate, StationRecipeAssignmentResponse, - _RecipeSummary, + RecipeSummary, ) from services import station_service @@ -40,7 +40,11 @@ async def create_new_station( return StationResponse.model_validate(station) -@router.get("/by-code/{code}/recipes", response_model=list[_RecipeSummary]) +# NOTE: this literal-prefix route must stay above the /{station_id} routes. +# The int-typed station_id param already guards against "by-code" being +# matched as a station id, but keeping the explicit order avoids surprises +# during refactors (e.g. if someone regroups handlers by HTTP method). +@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), @@ -56,10 +60,10 @@ async def list_recipes_by_station_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", + detail="Station not found", ) recipes = await station_service.list_station_recipes(db, station.id) - return [_RecipeSummary.model_validate(r) for r in recipes] + return [RecipeSummary.model_validate(r) for r in recipes] @router.get("/{station_id}", response_model=StationResponse) @@ -95,7 +99,7 @@ async def remove_station( await station_service.delete_station(db, station_id) -@router.get("/{station_id}/recipes", response_model=list[_RecipeSummary]) +@router.get("/{station_id}/recipes", response_model=list[RecipeSummary]) async def list_assigned_recipes( station_id: int, admin: User = Depends(require_admin_user), @@ -103,7 +107,7 @@ async def list_assigned_recipes( ): """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] + return [RecipeSummary.model_validate(r) for r in recipes] @router.post( diff --git a/server/schemas/station.py b/server/schemas/station.py index d44e870..7b0c6b0 100644 --- a/server/schemas/station.py +++ b/server/schemas/station.py @@ -45,7 +45,7 @@ class StationRecipeAssignmentResponse(BaseModel): assigned_at: datetime -class _RecipeSummary(BaseModel): +class RecipeSummary(BaseModel): model_config = ConfigDict(from_attributes=True) id: int code: str @@ -54,4 +54,4 @@ class _RecipeSummary(BaseModel): class StationWithRecipesResponse(StationResponse): - recipes: list[_RecipeSummary] = Field(default_factory=list) + recipes: list[RecipeSummary] = Field(default_factory=list) diff --git a/server/tests/test_stations_api.py b/server/tests/test_stations_api.py index 31058a1..c936ee8 100644 --- a/server/tests/test_stations_api.py +++ b/server/tests/test_stations_api.py @@ -134,3 +134,70 @@ async def test_list_recipes_by_unknown_code_404( headers=auth_headers(measurement_tec_user), ) assert resp.status_code == 404 + + +async def test_admin_can_list_stations(client: AsyncClient, admin_user): + await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-L1", "name": "A"}, + ) + await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-L2", "name": "B", "active": False}, + ) + resp = await client.get("/api/stations", headers=auth_headers(admin_user)) + assert resp.status_code == 200 + codes = {s["code"] for s in resp.json()} + assert {"ST-L1", "ST-L2"}.issubset(codes) + + resp_active = await client.get( + "/api/stations?active_only=true", headers=auth_headers(admin_user), + ) + assert resp_active.status_code == 200 + active_codes = {s["code"] for s in resp_active.json()} + assert "ST-L1" in active_codes + assert "ST-L2" not in active_codes + + +async def test_assign_recipe_not_found_returns_404( + client: AsyncClient, admin_user, +): + created = await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-NR", "name": "NR"}, + ) + sid = created.json()["id"] + resp = await client.post( + f"/api/stations/{sid}/recipes", + headers=auth_headers(admin_user), + json={"recipe_id": 99999}, + ) + assert resp.status_code == 404 + + +async def test_duplicate_assignment_returns_409( + client: AsyncClient, admin_user, db_session, +): + recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-DUP") + await db_session.commit() + created = await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-DUP-A", "name": "Dup"}, + ) + sid = created.json()["id"] + first = await client.post( + f"/api/stations/{sid}/recipes", + headers=auth_headers(admin_user), + json={"recipe_id": recipe.id}, + ) + assert first.status_code == 201 + second = await client.post( + f"/api/stations/{sid}/recipes", + headers=auth_headers(admin_user), + json={"recipe_id": recipe.id}, + ) + assert second.status_code == 409 From 2e4db53f6a20eb173fbc8c55345fe09e1c449671 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 23:04:41 +0200 Subject: [PATCH 10/14] feat(setup): seed ST-DEFAULT station and assign existing recipes Add _seed_default_station() helper to /api/setup/seed endpoint that creates the ST-DEFAULT station and assigns all active recipes to it, preserving existing behaviour after migration 002 runs on live DBs. Helper is idempotent and is called on both the normal seed path and the early-return path (when demo data already exists). Co-Authored-By: Claude Sonnet 4.6 --- server/routers/setup.py | 50 +++++++++++++++ server/tests/test_station_seed.py | 100 ++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 server/tests/test_station_seed.py diff --git a/server/routers/setup.py b/server/routers/setup.py index 7d9e403..5ade483 100644 --- a/server/routers/setup.py +++ b/server/routers/setup.py @@ -13,12 +13,14 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel from sqlalchemy import inspect as sa_inspect, select +from sqlalchemy.ext.asyncio import AsyncSession from config import settings from database import Base, engine, async_session_factory from models import ( User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, ) +from models.station import Station, StationRecipeAssignment from services.auth_service import hash_password from services.measurement_service import calculate_pass_fail @@ -161,6 +163,50 @@ def _generate_measurements_for_subtask( return measurements +async def _seed_default_station(session: AsyncSession, admin_user: User) -> None: + """Ensure ST-DEFAULT station exists and all active recipes are assigned to it. + + Idempotent: safe to call multiple times. Skips creation if the station + already exists and only adds assignments for recipes not yet assigned. + """ + existing = await session.execute( + select(Station).where(Station.code == "ST-DEFAULT") + ) + default_station = existing.scalar_one_or_none() + + if default_station is None: + default_station = Station( + code="ST-DEFAULT", + name="Default Station", + location="Initial seed - change me", + created_by=admin_user.id, + ) + session.add(default_station) + await session.flush() + await session.refresh(default_station) + + # Collect already-assigned recipe IDs to avoid unique-constraint violations. + existing_assignments = await session.execute( + select(StationRecipeAssignment.recipe_id).where( + StationRecipeAssignment.station_id == default_station.id + ) + ) + existing_ids = {row[0] for row in existing_assignments} + + recipes_result = await session.execute( + select(Recipe).where(Recipe.active == True) + ) + for r in recipes_result.scalars().all(): + if r.id not in existing_ids: + session.add(StationRecipeAssignment( + station_id=default_station.id, + recipe_id=r.id, + assigned_by=admin_user.id, + )) + + await session.flush() + + # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @@ -299,6 +345,7 @@ async def seed_demo_data(body: PasswordBody): select(Recipe).where(Recipe.code == "DEMO-001") ) if existing_recipe.scalar_one_or_none(): + await _seed_default_station(session, admin_user) return { "status": "ok", "message": "Demo data already exists, skipped", @@ -453,6 +500,9 @@ async def seed_demo_data(body: PasswordBody): session.add_all(ms) measurements_created += len(ms) + # ---- Default Station ---------------------------------------------- + await _seed_default_station(session, admin_user) + return { "status": "ok", "message": "Demo data seeded successfully", diff --git a/server/tests/test_station_seed.py b/server/tests/test_station_seed.py new file mode 100644 index 0000000..7d8b1ac --- /dev/null +++ b/server/tests/test_station_seed.py @@ -0,0 +1,100 @@ +"""Verify /api/setup/seed creates a default station with all recipes assigned. + +DEVIATION FROM PLAN: The real setup endpoint is NOT a single /api/setup/initialize. +The setup is split into separate endpoints: + - POST /api/setup/init-db (creates tables) + - POST /api/setup/seed (seeds users + demo recipe + stations) + +The seed body is {"password": "..."} with no load_demo_data flag (demo data +is always seeded). The station seed is added inside /api/setup/seed. + +The seed endpoint uses async_session_factory directly (not get_db dependency +injection), so we must monkeypatch routers.setup.engine and +routers.setup.async_session_factory to point at the test SQLite engine, +exactly as done in test_setup.py. +""" +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import settings +from models.station import Station, StationRecipeAssignment +from models.recipe import Recipe +from tests.conftest import test_engine, TestSessionFactory + + +SETUP_PWD = "test-setup-pwd" + + +@pytest.fixture(autouse=True) +def enable_setup(monkeypatch): + """Enable setup endpoints and redirect engine/session to test SQLite DB.""" + monkeypatch.setattr(settings, "setup_password", SETUP_PWD) + import routers.setup as setup_mod + monkeypatch.setattr(setup_mod, "engine", test_engine) + monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory) + + +async def _seed(client: AsyncClient) -> dict: + """Init DB tables then run seed; return seed response JSON.""" + resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD}) + assert resp.status_code == 200, resp.text + resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD}) + assert resp.status_code == 200, resp.text + return resp.json() + + +@pytest.mark.asyncio +async def test_setup_seed_creates_default_station_and_assigns_recipes( + client: AsyncClient, + db_session: AsyncSession, +): + """After seeding, ST-DEFAULT station must exist and all active recipes assigned.""" + await _seed(client) + + # Default station must exist and be active. + result = await db_session.execute( + select(Station).where(Station.code == "ST-DEFAULT") + ) + default = result.scalar_one_or_none() + assert default is not None, "ST-DEFAULT station was not created" + assert default.active is True + + # All active recipes must be assigned to the default station. + active_recipes_result = await db_session.execute( + select(Recipe).where(Recipe.active == True) + ) + active_recipes = active_recipes_result.scalars().all() + assert len(active_recipes) > 0, "demo seed must create at least one active recipe" + + assignments_result = await db_session.execute( + select(StationRecipeAssignment).where( + StationRecipeAssignment.station_id == default.id + ) + ) + n_assignments = len(assignments_result.scalars().all()) + assert n_assignments == len(active_recipes), ( + f"Expected {len(active_recipes)} assignment(s), got {n_assignments}" + ) + + +@pytest.mark.asyncio +async def test_setup_seed_station_idempotent( + client: AsyncClient, + db_session: AsyncSession, +): + """Running seed twice must not duplicate ST-DEFAULT or its assignments.""" + # First run — creates everything. + await _seed(client) + + # Second run — recipe DEMO-001 already exists → seed returns early. + # ST-DEFAULT must still exist (not re-created, not duplicated). + resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD}) + assert resp.status_code == 200, resp.text + + stations_result = await db_session.execute( + select(Station).where(Station.code == "ST-DEFAULT") + ) + stations = stations_result.scalars().all() + assert len(stations) == 1, f"Expected exactly 1 ST-DEFAULT, got {len(stations)}" From 958f6ac0b0452eb859f5ec0a9e3e6b5b2c8b09f4 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 23:18:24 +0200 Subject: [PATCH 11/14] feat(client): add STATION_CODE env var and config attribute Reads STATION_CODE from the environment and exposes it as Config.STATION_CODE (None when unset or empty). Adds the variable to .env.example with a per-station deployment note, and covers both read and missing-key paths with new pytest tests. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 ++++ client/config.py | 4 ++++ client/tests/test_config_station.py | 17 +++++++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 client/tests/test_config_station.py diff --git a/.env.example b/.env.example index e4a89fa..eb2ded8 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,10 @@ CLIENT_HOST=0.0.0.0 CLIENT_PORT=5000 CLIENT_SECRET_KEY=change-this-to-another-random-secret-key API_SERVER_URL=http://localhost:8000 +# 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 # --- File Storage --- UPLOAD_DIR=server/uploads diff --git a/client/config.py b/client/config.py index 51bd2f2..3a3ee54 100644 --- a/client/config.py +++ b/client/config.py @@ -22,6 +22,10 @@ class Config: PERMANENT_SESSION_LIFETIME = 28800 # 8 hours WTF_CSRF_TIME_LIMIT = 3600 # 1 hour + # Station identity: each deployed client container sets this to the station + # code it belongs to. Empty/None means "not configured". + STATION_CODE: str | None = os.getenv("STATION_CODE") or None + # Babel i18n BABEL_DEFAULT_LOCALE = "it" BABEL_DEFAULT_TIMEZONE = "Europe/Rome" diff --git a/client/tests/test_config_station.py b/client/tests/test_config_station.py new file mode 100644 index 0000000..96bb825 --- /dev/null +++ b/client/tests/test_config_station.py @@ -0,0 +1,17 @@ +"""Tests that STATION_CODE is loaded from env and exposed on the client Config class.""" +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.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.Config.STATION_CODE is None From a4a849920ff523f56585aa2e97204ed302fa9c51 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 23:24:19 +0200 Subject: [PATCH 12/14] feat(client): add get_station_recipes helper on APIClient Co-Authored-By: Claude Sonnet 4.6 --- client/services/api_client.py | 6 ++++++ client/tests/test_api_client_stations.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 client/tests/test_api_client_stations.py diff --git a/client/services/api_client.py b/client/services/api_client.py index de6e0ac..4d3604a 100644 --- a/client/services/api_client.py +++ b/client/services/api_client.py @@ -131,5 +131,11 @@ class APIClient: "detail": f"Errore di connessione al server: {str(e)}" } + # --- Domain helpers --- + + def get_station_recipes(self, station_code: str) -> dict[str, Any]: + """Return the list of active recipes assigned to the given station.""" + return self.get(f"/api/stations/by-code/{station_code}/recipes") + api_client = APIClient() diff --git a/client/tests/test_api_client_stations.py b/client/tests/test_api_client_stations.py new file mode 100644 index 0000000..1d9ae07 --- /dev/null +++ b/client/tests/test_api_client_stations.py @@ -0,0 +1,15 @@ +"""Tests for the station-related helpers in api_client.""" +from unittest.mock import patch + +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") + # The helper must call the underlying .get() with the exact endpoint path. + called_args, called_kwargs = mock_get.call_args + assert called_args[0] == "/api/stations/by-code/ST-001/recipes" + assert result == [{"id": 1, "code": "R1", "name": "R1", "active": True}] From 946264637b71a7ee193e63a9296da5d020993001 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 18 Apr 2026 08:28:46 +0200 Subject: [PATCH 13/14] feat(client): filter select_recipe by STATION_CODE with error fallback Replace generic /api/recipes call with api_client.get_station_recipes(STATION_CODE). Return 503 station_not_configured.html when STATION_CODE env var is unset. Add station indicator to recipe selection page header. Co-Authored-By: Claude Sonnet 4.6 --- client/blueprints/measure.py | 15 +++++++- .../errors/station_not_configured.html | 33 +++++++++++++++++ client/templates/measure/select_recipe.html | 3 ++ client/tests/test_measure.py | 19 ++++++---- client/tests/test_measure_station_filter.py | 37 +++++++++++++++++++ 5 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 client/templates/errors/station_not_configured.html create mode 100644 client/tests/test_measure_station_filter.py diff --git a/client/blueprints/measure.py b/client/blueprints/measure.py index b0eae1d..e7466c2 100644 --- a/client/blueprints/measure.py +++ b/client/blueprints/measure.py @@ -22,8 +22,18 @@ measure_bp = Blueprint("measure", __name__) @role_required("MeasurementTec") def select_recipe(): """Recipe selection page with search and barcode support.""" - # Load recipes from API - resp = api_client.get("/api/recipes", params={"per_page": 100}) + # Fail-fast if STATION_CODE is not configured + if not Config.STATION_CODE: + return render_template("errors/station_not_configured.html"), 503 + + # Load recipes filtered by station + try: + resp = api_client.get_station_recipes(Config.STATION_CODE) + except Exception as e: + return render_template( + "errors/station_not_configured.html", error=str(e), + ), 502 + if isinstance(resp, dict) and resp.get("error"): flash( _("Errore nel caricamento delle ricette: %(detail)s", @@ -43,6 +53,7 @@ def select_recipe(): return render_template( "measure/select_recipe.html", recipes=recipes, + station_code=Config.STATION_CODE, auto_recipe_code=auto_recipe_code, auto_lot=auto_lot, auto_serial=auto_serial, diff --git a/client/templates/errors/station_not_configured.html b/client/templates/errors/station_not_configured.html new file mode 100644 index 0000000..d19e6d8 --- /dev/null +++ b/client/templates/errors/station_not_configured.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}{{ _('Stazione non configurata') }} — TieMeasureFlow{% endblock %} + +{% block content %} +
+
+
+ + + +
+ +

+ {{ _('Stazione non configurata') }} +

+ +

+ {{ _('Questo client non ha impostato la variabile di ambiente STATION_CODE.') }} +

+ +

+ {{ _('Contattare il responsabile IT: il file .env del container deve contenere STATION_CODE con il codice della stazione assegnata.') }} +

+ + {% if error %} +
{{ error }}
+ {% endif %} +
+
+{% endblock %} diff --git a/client/templates/measure/select_recipe.html b/client/templates/measure/select_recipe.html index 2d3aaf0..22f601a 100644 --- a/client/templates/measure/select_recipe.html +++ b/client/templates/measure/select_recipe.html @@ -75,6 +75,9 @@

{{ _('Scegli la ricetta di misura da eseguire') }}

+

+ {{ _('Stazione') }}: {{ station_code }} +

diff --git a/client/tests/test_measure.py b/client/tests/test_measure.py index 3309737..9992c4b 100644 --- a/client/tests/test_measure.py +++ b/client/tests/test_measure.py @@ -8,15 +8,18 @@ import pytest class TestSelectRecipe: """GET /measure/select tests.""" - def test_select_recipe_renders(self, logged_in_client, mock_api_client): + def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch): """Recipe selection page renders for MeasurementTec role.""" - mock_api_client.get.return_value = { - "items": [ - {"id": 1, "code": "REC-001", "name": "Test Recipe"}, - ], - "total": 1, - "pages": 1, - } + monkeypatch.setenv("STATION_CODE", "ST-TEST") + import config + import importlib + importlib.reload(config) + import blueprints.measure + importlib.reload(blueprints.measure) + + mock_api_client.get_station_recipes.return_value = [ + {"id": 1, "code": "REC-001", "name": "Test Recipe"}, + ] resp = logged_in_client.get("/measure/select") assert resp.status_code == 200 diff --git a/client/tests/test_measure_station_filter.py b/client/tests/test_measure_station_filter.py new file mode 100644 index 0000000..59efe10 --- /dev/null +++ b/client/tests/test_measure_station_filter.py @@ -0,0 +1,37 @@ +"""Verify that /measure/select reads STATION_CODE and filters recipes via the server.""" +import importlib +from unittest.mock import patch, MagicMock + + +def _reload_measure(monkeypatch, station_code=None): + """Reload config and measure module under the given STATION_CODE env.""" + if station_code is None: + monkeypatch.delenv("STATION_CODE", raising=False) + else: + monkeypatch.setenv("STATION_CODE", station_code) + import config + importlib.reload(config) + import blueprints.measure + importlib.reload(blueprints.measure) + + +def test_select_recipe_calls_station_endpoint(logged_in_client, monkeypatch): + _reload_measure(monkeypatch, station_code="ST-TEST") + from blueprints import measure as measure_bp_mod + with patch.object(measure_bp_mod, "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): + _reload_measure(monkeypatch, station_code=None) + resp = logged_in_client.get("/measure/select") + assert resp.status_code == 503 + body = resp.data.lower() + assert b"station_code" in body or b"stazione" in body From a6c335ca8bb9207070556ee57d3241a593225e51 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 25 Apr 2026 11:50:33 +0200 Subject: [PATCH 14/14] feat(client): add admin GUI for stations CRUD and recipe assignments Adds a complete browser-based interface for managing stations, closing the last deliverable of rev04 Phase 1. - New /admin/stations page with stations table, create/edit modal, delete confirmation and dedicated recipe-assignment modal - Proxy endpoints under /admin/api/stations/* covering CRUD and recipe assign/unassign so all admin operations stay behind the Flask CSRF + admin_required guard - Navbar entry "Stazioni" (desktop + mobile), visible to admins only - 10 new tests covering page render, every proxy and the non-admin redirect Co-Authored-By: Claude Opus 4.7 (1M context) --- client/blueprints/admin.py | 96 ++++ client/templates/admin/stations.html | 568 ++++++++++++++++++++++++ client/templates/components/navbar.html | 28 +- client/tests/test_admin_stations.py | 134 ++++++ 4 files changed, 824 insertions(+), 2 deletions(-) create mode 100644 client/templates/admin/stations.html create mode 100644 client/tests/test_admin_stations.py diff --git a/client/blueprints/admin.py b/client/blueprints/admin.py index c2abaec..2b38fe3 100644 --- a/client/blueprints/admin.py +++ b/client/blueprints/admin.py @@ -43,6 +43,31 @@ def user_list(): return render_template("admin/users.html", users=users) +@admin_bp.route("/stations") +@login_required +@admin_required +def station_list(): + """Station management page.""" + resp = api_client.get("/api/stations") + if isinstance(resp, dict) and resp.get("error"): + flash(_("Errore nel caricamento delle stazioni: %(error)s", error=resp.get("detail", "")), "error") + stations = [] + elif isinstance(resp, list): + stations = resp + else: + stations = [] + + recipes_resp = api_client.get("/api/recipes") + if isinstance(recipes_resp, list): + all_recipes = recipes_resp + elif isinstance(recipes_resp, dict) and isinstance(recipes_resp.get("items"), list): + all_recipes = recipes_resp["items"] + else: + all_recipes = [] + + return render_template("admin/stations.html", stations=stations, all_recipes=all_recipes) + + # ============================================================================ # API PROXY AJAX (JSON) # ============================================================================ @@ -102,3 +127,74 @@ def api_toggle_active(user_id: int): return jsonify(resp), resp.get("status_code", 500) return jsonify(resp), 200 + + +# --- Stations --- + +@admin_bp.route("/api/stations", methods=["POST"]) +@login_required +@admin_required +def api_create_station(): + """Proxy: Create a new station.""" + data = request.get_json(silent=True) or {} + resp = api_client.post("/api/stations", data=data) + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 201 + + +@admin_bp.route("/api/stations/", methods=["PUT"]) +@login_required +@admin_required +def api_update_station(station_id: int): + """Proxy: Update a station.""" + data = request.get_json(silent=True) or {} + resp = api_client.put(f"/api/stations/{station_id}", data=data) + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 200 + + +@admin_bp.route("/api/stations/", methods=["DELETE"]) +@login_required +@admin_required +def api_delete_station(station_id: int): + """Proxy: Delete a station.""" + resp = api_client.delete(f"/api/stations/{station_id}") + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify({"deleted": True}), 200 + + +@admin_bp.route("/api/stations//recipes", methods=["GET"]) +@login_required +@admin_required +def api_list_station_recipes(station_id: int): + """Proxy: List recipes assigned to a station.""" + resp = api_client.get(f"/api/stations/{station_id}/recipes") + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 200 + + +@admin_bp.route("/api/stations//recipes", methods=["POST"]) +@login_required +@admin_required +def api_assign_recipe(station_id: int): + """Proxy: Assign a recipe to a station.""" + data = request.get_json(silent=True) or {} + resp = api_client.post(f"/api/stations/{station_id}/recipes", data=data) + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 201 + + +@admin_bp.route("/api/stations//recipes/", methods=["DELETE"]) +@login_required +@admin_required +def api_unassign_recipe(station_id: int, recipe_id: int): + """Proxy: Remove a recipe assignment from a station.""" + resp = api_client.delete(f"/api/stations/{station_id}/recipes/{recipe_id}") + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify({"deleted": True}), 200 diff --git a/client/templates/admin/stations.html b/client/templates/admin/stations.html new file mode 100644 index 0000000..2c45cbe --- /dev/null +++ b/client/templates/admin/stations.html @@ -0,0 +1,568 @@ +{% extends "base.html" %} + +{% block title %}{{ _('Gestione Stazioni') }} - TieMeasureFlow{% endblock %} + +{% block content %} + + +
+ + +
+

{{ _('Gestione Stazioni') }}

+

{{ _('Crea, modifica e gestisci stazioni di misurazione e relative ricette assegnate') }}

+
+ + +
+
+ + + + +
+ + +
+ + +
+
+ + + + + + + + + + + + + +
{{ _('Codice') }}{{ _('Nome') }}{{ _('Stato') }}{{ _('Azioni') }}
+
+ + +
+ +
+ {{ _('stazioni') }} +
+ + +
+
+
+ +
+

+ +
+ +
+
+ + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+
+
+ +
+
+

{{ _('Ricette Assegnate') }}

+

+
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+

+ {{ _('Ricette correntemente assegnate') }} () +

+
+ + +
+
+ + +
+ +
+ +
+
+
+ + +
+
+
+

{{ _('Conferma Eliminazione') }}

+

+ {{ _('Sei sicuro di voler eliminare la stazione') }} + ? +
{{ _('Verranno rimosse anche tutte le assegnazioni di ricette.') }} +

+
+ + +
+
+
+ +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/client/templates/components/navbar.html b/client/templates/components/navbar.html index 6ffacae..1712810 100644 --- a/client/templates/components/navbar.html +++ b/client/templates/components/navbar.html @@ -79,7 +79,7 @@ class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors duration-200 - {% if request.endpoint and request.endpoint.startswith('admin.') %} + {% if request.endpoint == 'admin.user_list' %} text-primary bg-primary-50 dark:bg-primary-900/20 {% endif %}"> @@ -88,6 +88,20 @@ {{ _('Utenti') }} + + + + + + + {{ _('Stazioni') }} + {% endif %} @@ -264,7 +278,7 @@ {% endif %} - {# Admin: Utenti #} + {# Admin: Utenti + Stazioni #} {% if current_user.get('is_admin') %} + + + + {{ _('Stazioni') }} + {% endif %} diff --git a/client/tests/test_admin_stations.py b/client/tests/test_admin_stations.py new file mode 100644 index 0000000..e3d93fa --- /dev/null +++ b/client/tests/test_admin_stations.py @@ -0,0 +1,134 @@ +"""Tests for the admin Stations management UI and proxy endpoints.""" +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_admin_api(): + """Patch api_client used by the admin blueprint.""" + mock = MagicMock() + with patch("blueprints.admin.api_client", mock): + yield mock + + +def test_station_list_page_requires_admin(logged_in_client, mock_admin_api): + mock_admin_api.get.return_value = [] + resp = logged_in_client.get("/admin/stations") + assert resp.status_code == 200 + assert b"Gestione Stazioni" in resp.data or b"stations" in resp.data.lower() + + +def test_station_list_page_calls_correct_endpoints(logged_in_client, mock_admin_api): + mock_admin_api.get.side_effect = [ + [{"id": 1, "code": "ST-001", "name": "Linea A", "location": None, + "notes": None, "active": True, "created_by": 1, "created_at": "2026-04-25T10:00:00"}], + [{"id": 1, "code": "REC-001", "name": "Test Recipe", "active": True}], + ] + resp = logged_in_client.get("/admin/stations") + assert resp.status_code == 200 + calls = [c.args[0] for c in mock_admin_api.get.call_args_list] + assert "/api/stations" in calls + assert "/api/recipes" in calls + + +def test_create_station_proxy(logged_in_client, mock_admin_api): + mock_admin_api.post.return_value = { + "id": 5, "code": "ST-005", "name": "Nuova", + "location": None, "notes": None, "active": True, + "created_by": 1, "created_at": "2026-04-25T10:00:00", + } + resp = logged_in_client.post( + "/admin/api/stations", + json={"code": "ST-005", "name": "Nuova", "active": True}, + ) + assert resp.status_code == 201 + body = resp.get_json() + assert body["code"] == "ST-005" + mock_admin_api.post.assert_called_once_with( + "/api/stations", + data={"code": "ST-005", "name": "Nuova", "active": True}, + ) + + +def test_create_station_propagates_error(logged_in_client, mock_admin_api): + mock_admin_api.post.return_value = { + "error": True, "status_code": 409, "detail": "code already exists", + } + resp = logged_in_client.post( + "/admin/api/stations", json={"code": "ST-001", "name": "x"}, + ) + assert resp.status_code == 409 + assert resp.get_json()["detail"] == "code already exists" + + +def test_update_station_proxy(logged_in_client, mock_admin_api): + mock_admin_api.put.return_value = { + "id": 3, "code": "ST-003", "name": "Aggiornata", + "location": "Reparto B", "notes": None, "active": False, + "created_by": 1, "created_at": "2026-04-25T10:00:00", + } + resp = logged_in_client.put( + "/admin/api/stations/3", + json={"name": "Aggiornata", "location": "Reparto B", "active": False}, + ) + assert resp.status_code == 200 + mock_admin_api.put.assert_called_once_with( + "/api/stations/3", + data={"name": "Aggiornata", "location": "Reparto B", "active": False}, + ) + + +def test_delete_station_proxy(logged_in_client, mock_admin_api): + mock_admin_api.delete.return_value = {} + resp = logged_in_client.delete("/admin/api/stations/7") + assert resp.status_code == 200 + assert resp.get_json() == {"deleted": True} + mock_admin_api.delete.assert_called_once_with("/api/stations/7") + + +def test_assign_recipe_proxy(logged_in_client, mock_admin_api): + mock_admin_api.post.return_value = { + "id": 1, "station_id": 2, "recipe_id": 10, + "assigned_by": 1, "assigned_at": "2026-04-25T10:00:00", + } + resp = logged_in_client.post( + "/admin/api/stations/2/recipes", json={"recipe_id": 10}, + ) + assert resp.status_code == 201 + mock_admin_api.post.assert_called_once_with( + "/api/stations/2/recipes", data={"recipe_id": 10}, + ) + + +def test_unassign_recipe_proxy(logged_in_client, mock_admin_api): + mock_admin_api.delete.return_value = {} + resp = logged_in_client.delete("/admin/api/stations/2/recipes/10") + assert resp.status_code == 200 + mock_admin_api.delete.assert_called_once_with("/api/stations/2/recipes/10") + + +def test_list_station_recipes_proxy(logged_in_client, mock_admin_api): + mock_admin_api.get.return_value = [ + {"id": 1, "code": "REC-001", "name": "R1", "active": True}, + ] + resp = logged_in_client.get("/admin/api/stations/2/recipes") + assert resp.status_code == 200 + assert resp.get_json() == [{"id": 1, "code": "REC-001", "name": "R1", "active": True}] + mock_admin_api.get.assert_called_once_with("/api/stations/2/recipes") + + +def test_non_admin_cannot_access(client, mock_admin_api): + """Non-admin user gets redirected away from station management.""" + with client.session_transaction() as sess: + sess["api_key"] = "test-key" + sess["user"] = { + "id": 2, + "username": "operator", + "roles": ["MeasurementTec"], + "is_admin": False, + "active": True, + } + sess["user_id"] = 2 + resp = client.get("/admin/stations", follow_redirects=False) + assert resp.status_code in (301, 302)