f71bbf398e
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>
2196 lines
74 KiB
Markdown
2196 lines
74 KiB
Markdown
# 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**
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```python
|
|
# 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):
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```python
|
|
# 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:
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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:
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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:
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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`):
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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:
|
|
|
|
```python
|
|
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**
|
|
|
|
```html
|
|
{# 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:
|
|
|
|
```html
|
|
<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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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`):
|
|
|
|
```python
|
|
@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:
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```html
|
|
{# 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```html
|
|
{# 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**
|
|
|
|
```bash
|
|
git add client/templates/admin/station_detail.html
|
|
git commit -m "feat(client): add admin station detail with recipe assignment UI"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Navbar Link to Stations Admin
|
|
|
|
**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:
|
|
|
|
```html
|
|
{% 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
cd client && pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
|
|
```
|
|
|
|
- [ ] **Step 2: Update per-locale .po files**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
cd server && alembic -c migrations/alembic.ini upgrade head
|
|
```
|
|
Expected: migration `002_add_stations` applied without errors. Check MySQL:
|
|
```sql
|
|
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:
|
|
```sql
|
|
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:
|
|
```bash
|
|
cd server && alembic -c migrations/alembic.ini downgrade -1
|
|
```
|
|
Expected: both tables dropped without error.
|
|
|
|
Then re-upgrade:
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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.
|