6 Commits

Author SHA1 Message Date
Adriano 85a00dea1b fix(api): allow MeasurementTec to list measurements for task_complete
GET /api/measurements required Metrologist, but the operator workflow
calls it from task_complete.html to render the post-recipe riepilogo —
silently empty for every non-Metrologist user. Open to any authenticated
user; payload carries no PII beyond numeric values + recipe ref.

Regression test added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:42:16 +02:00
Adriano e4eb4cd932 docs: refresh stato + roadmap with smoke-test findings; fix sort syntax
STATO_PROGETTO.md
- Bumped snapshot date to 2026-04-27.
- Added "Hardening post-restructure (smoke test 2026-04-26)" section
  recording the four runtime regressions surfaced by the local smoke
  test (env_file path, missing Station import, UPLOAD_DIR default,
  apostrophe-in-translation in Alpine), plus the recipe-assignment
  modal UX rework and the new node-based JS syntax test guard.
- Added "Smoke test status" section listing the verified end-to-end
  flow (MySQL up, alembic, seed, login, admin pages, MeasurementTec
  workflow, hot reload).
- Bumped frontend test count 44 → 46 to reflect
  test_template_js_syntax.py.

ROADMAP.md
- Added a tech-debt entry for the user-reported task_complete
  riepilogo rendering anomaly (still under investigation: the curl
  fetch returns the table populated, but the user reports an empty
  body in the browser).
- Added a tech-debt entry for the still-pending Docker container
  smoke test of the new uv-based Dockerfiles.

src/frontend/flask_app/templates/measure/task_complete.html
- Replaced sort(attribute='task_info.order_index,subtask.marker_number')
  with two chained stable sorts. Jinja's sort filter does not accept a
  comma-separated multi-attribute string; the previous form sorted on
  a non-existent attribute and only worked by accident because the
  API already returned rows in the desired order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:10:58 +02:00
Adriano 4de7d78b66 docs: document stations end-to-end (user guide + API + deployment)
Stations were the headline V2.0.0 feature but had no user-facing
documentation outside the architecture page. Filled the gap across
the three operational docs.

USER_GUIDE.md
- New entries in "Key Concepts": Station and Station assignment.
- New "Recipes you see are filtered by station" subsection in the
  MeasurementTec workflow, explaining why the Select Recipe page may
  legitimately show fewer recipes than expected and what the
  "Stazione non configurata" error means at the operator level.
- New "Station Management" section under Admin Workflow covering:
  the mental model, station create/edit/delete, the two-column
  recipe-assignment modal, the immutable-code rule, the role of the
  ST-DEFAULT seed station, and the tablet deployment cheat sheet.
- Admin role description updated to mention stations.

DEPLOYMENT.md
- Environment Variables Reference: added STATION_CODE row and noted
  that an empty value triggers the deliberate fail-fast HTTP 503 on
  /measure/select. Updated RATE_LIMIT_GENERAL default (300, per the
  V2.0.0 perf change). Clarified UPLOAD_DIR resolves against the
  project root.

API.md
- New "Stations" endpoint section listing all eight routes with
  request/response examples and the 401/403/404/409 error contract:
  GET / POST /stations, GET /stations/{id}, PUT /stations/{id},
  DELETE /stations/{id}, GET /stations/{id}/recipes,
  GET /stations/by-code/{code}/recipes (the operator-facing one used
  by the Flask client), POST /stations/{id}/recipes,
  DELETE /stations/{id}/recipes/{recipe_id}.
- TOC updated with the new "Stations" anchor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:26:43 +02:00
Adriano 2a2d40bec9 fix(admin): two more apostrophe-in-translation regressions + UX rework
UX rework — recipe assignment modal on /admin/stations
- Replace the single "select recipe + Assegna" dropdown with a two-
  column layout: Ricette disponibili (left) and Assegnate alla
  stazione (right), each row with an inline action button. Top search
  filter narrows both columns at once. Empty states explain *why* the
  list is empty (no recipes in system, all already assigned, no match
  for the filter).
- Rationale: the old dropdown silently hid every option once a recipe
  was assigned, leaving the user unable to tell whether the system
  was broken or simply out of unassigned recipes.

Apostrophe regressions
- /admin/stations alert/errorMsg literals reworded with double-quoted
  outer JS strings ("Errore nella eliminazione" / "...assegnazione").
- /admin/users toggle confirm modal: x-text expression contained
  '{{ _('… l\'utente') }}'. Inside a Jinja-rendered HTML attribute,
  the apostrophe in "l'utente" closed the JS literal early, killing
  the binding. Fixed by using &quot; as the JS string delimiter so
  the inner apostrophe is harmless.

Alpine x-if templates can't host nested templates
- Replaced two nested-template empty-state blocks with x-text bound
  to computed getters (unassignedEmptyMessage,
  assignedEmptyMessage). Alpine errored with
  "Cannot set properties of null (setting '_x_dataStack')" when the
  outer template's child wasn't a single root element.

Test guard widened
- src/frontend/flask_app/tests/test_template_js_syntax.py now also
  parses every Alpine attribute (x-*, @*, :*) on the rendered HTML
  and runs `node --check` on each expression wrapped in `void (…)`.
  Previously it only inspected inline <script> bodies, which is why
  the x-text bug on /admin/users slipped through. Verified the
  extended test catches the original l'utente regression by reverting
  + running + restoring.

Layout regression — UPLOAD_DIR defaulted to server/uploads
- The previous .env.example shipped UPLOAD_DIR=server/uploads, which
  matched the V1.x layout but pointed outside the new project tree.
  Updated to UPLOAD_DIR=uploads so files land in the project-root
  uploads/ volume that src/backend/config.py.upload_path resolves.
- Added uploads/general/ to .gitignore (per-user uploads, not source).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:49:23 +02:00
Adriano 6e284b0c0c fix(admin): apostrophe-in-translation broke /admin/stations Alpine bindings
Two error messages on /admin/stations rendered Italian translations
that contain an apostrophe inside a single-quoted JS literal:

  alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');

Jinja outputs the apostrophe verbatim, so the JS string closed
prematurely:

  alert(result.detail || 'Errore nell'eliminazione');

That syntax error blew up the entire <script> block, which means
stationManagement() was never defined and EVERY Alpine binding on the
page failed silently. Symptom: clicking "Nuova Stazione" did nothing,
DevTools showed "Alpine Expression Error: openCreateModal is not
defined".

Fix: swap outer JS quotes to double quotes and reword the IT string so
the apostrophe disappears anyway ("Errore nella eliminazione",
"Errore nella assegnazione"). Same for the assignment-error path.

Regression guard: src/frontend/flask_app/tests/test_template_js_syntax.py
renders /admin/stations and /admin/users with the IT locale forced,
extracts every inline <script>, and runs `node --check` on each. The
test is skipped if `node` is not on PATH so CI without Node still
passes. Verified the test catches the original bug (revert + run +
fail) before re-applying the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:32:32 +02:00
Adriano 742cc1fb58 fix(v2): two regressions surfaced by the local smoke test
Both came from the src/ restructure and only show up at runtime, so
the test suite had not caught them.

- src/backend/config.py: env_file was "../../.env", which pydantic-
  settings resolves against the *cwd*, not the file. Running uvicorn
  or alembic from the project root therefore looked for
  ../../.env one level above the repo and silently fell back to the
  default DB_PASSWORD ("change_me_in_production"), hiding the real
  password. Now resolved as Path(__file__).resolve().parents[2] /
  ".env" so the lookup is always against the project root regardless
  of cwd.

- src/backend/models/orm/__init__.py: Station and
  StationRecipeAssignment were never imported here, so anything that
  triggers Base.metadata.create_all without first importing the
  setup router (which has its own Station import) ended up with no
  stations / station_recipe_assignments tables. Verified locally:
  /api/setup/seed used to fail with "Table tiemeasureflow.stations
  doesn't exist" before this fix.

- .gitignore: ignore src/frontend/flask_app/package.json and
  package-lock.json (local npm-install artifacts; the Dockerfile
  installs tailwindcss directly).

Smoke verified end-to-end: uvicorn + gunicorn + MySQL, login + admin
stations + select_recipe + admin users all 200 OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:19:36 +02:00
15 changed files with 684 additions and 75 deletions
+4 -1
View File
@@ -26,7 +26,10 @@ API_SERVER_URL=http://localhost:8000
STATION_CODE=ST-DEFAULT
# --- File Storage ---
UPLOAD_DIR=server/uploads
# Resolved against the project root in src/backend/config.py.
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
# volume in production.
UPLOAD_DIR=uploads
MAX_UPLOAD_SIZE_MB=50
# --- Setup Page ---
+4
View File
@@ -38,6 +38,7 @@ uploads/images/*
uploads/pdfs/*
uploads/logos/*
uploads/reports/*
uploads/general/
!uploads/images/.gitkeep
!uploads/pdfs/.gitkeep
!uploads/logos/.gitkeep
@@ -48,6 +49,8 @@ src/frontend/flask_app/static/css/tailwind.css
# Node
node_modules/
src/frontend/flask_app/package.json
src/frontend/flask_app/package-lock.json
# Flask-Babel compiled
*.mo
@@ -70,3 +73,4 @@ nul
# Competitor analysis (local only)
Concorrente/
docker-compose.override.yml
+184
View File
@@ -23,6 +23,7 @@ TieMeasureFlow provides a REST API built with FastAPI for managing measurement t
- [Measurements](#measurements)
- [Files](#files)
- [Settings](#settings)
- [Stations](#stations)
- [Statistics](#statistics)
- [Reports](#reports)
7. [Pagination](#pagination)
@@ -1101,6 +1102,189 @@ Content-Type: multipart/form-data
---
### Stations
Stations model the physical measurement posts on the shop floor. Each Flask client identifies itself through `STATION_CODE`, and the operator only sees recipes assigned to that station. See the user guide section "Admin Workflow → Station Management" for the operational model.
#### GET `/stations`
List stations. **Admin only.**
Query parameters:
- `active_only` (bool, default `false`): if `true`, return only stations where `active = true`.
Response `200`:
```json
[
{
"id": 1,
"code": "ST-DEFAULT",
"name": "Default Station",
"location": "Initial seed - change me",
"notes": null,
"active": true,
"created_by": 5,
"created_at": "2026-04-26T10:12:06"
}
]
```
Errors:
- 401: Missing or invalid API key
- 403: Admin role required
---
#### POST `/stations`
Create a station. **Admin only.**
Request body:
```json
{
"code": "ST-LINEA-A",
"name": "Linea A — Tornitura alberi",
"location": "Reparto 2 — Cella 3",
"notes": "Provisioned 2026-04-26 by Adriano",
"active": true
}
```
`code` (1-100 chars) and `name` (1-255 chars) are required. `location` is optional (≤ 255 chars). `active` defaults to `true`.
Response `201`: same shape as `GET /stations` items.
Errors:
- 400: Invalid payload (missing `code`/`name`, exceeded length)
- 403: Admin role required
- 409: Station with that code already exists
---
#### GET `/stations/{station_id}`
Get a single station by id. **Admin only.**
Response `200`: same as the list item shape.
Errors:
- 403: Admin role required
- 404: Station not found
---
#### PUT `/stations/{station_id}`
Update a station's editable fields. **Admin only.**
Request body (all fields optional):
```json
{
"name": "Linea A — riconfigurata",
"location": "Reparto 2 — Cella 4",
"notes": "Moved cell on 2026-05-12",
"active": false
}
```
Note: the `code` field is not in the schema. Codes are immutable on purpose (changing the code would orphan every tablet pointing at it).
Response `200`: the updated station.
Errors:
- 400: Invalid payload
- 403: Admin role required
- 404: Station not found
---
#### DELETE `/stations/{station_id}`
Delete a station. **Admin only.**
The deletion cascades to every row in `station_recipe_assignments` for that station. Existing measurements are NOT affected (measurements link to recipe versions, not stations).
Response `204`: no body.
Errors:
- 403: Admin role required
- 404: Station not found
---
#### GET `/stations/{station_id}/recipes`
Admin view of the recipes currently assigned to a station. Returns the same projection used by the assignment modal in `/admin/stations`. **Admin only.**
Response `200`:
```json
[
{
"id": 2,
"code": "DEMO-001",
"name": "Demo Measurement Recipe",
"active": true
}
]
```
Errors:
- 403: Admin role required
- 404: Station not found
---
#### GET `/stations/by-code/{code}/recipes`
**Operator view** (any authenticated user, no admin requirement). Returns the active recipes assigned to the station whose code matches `{code}`. The Flask client calls this endpoint at every page load of `/measure/select`, passing its own `STATION_CODE`.
Response `200`: same shape as `GET /stations/{station_id}/recipes`.
Errors:
- 401: Missing or invalid API key
- 404: Station not found OR station is not active (operator-facing endpoint deliberately treats both cases as 404 to avoid leaking the existence of disabled stations)
---
#### POST `/stations/{station_id}/recipes`
Assign an existing recipe to a station. **Admin only.**
Request body:
```json
{ "recipe_id": 2 }
```
Response `201`:
```json
{
"id": 1,
"station_id": 1,
"recipe_id": 2,
"assigned_by": 5,
"assigned_at": "2026-04-26T10:12:06"
}
```
Errors:
- 403: Admin role required
- 404: Station or recipe not found
- 409: Recipe already assigned to this station
---
#### DELETE `/stations/{station_id}/recipes/{recipe_id}`
Remove an existing assignment. **Admin only.**
Response `204`: no body.
Errors:
- 403: Admin role required
- 404: Station/recipe not found, or no assignment between them
---
### Statistics
All statistics endpoints **require Metrologist role**.
+4 -3
View File
@@ -134,10 +134,11 @@ SSL_KEYFILE=
| `CLIENT_HOST` | string | 0.0.0.0 | Flask client bind address |
| `CLIENT_PORT` | int | 5000 | Flask client port |
| `SERVER_CORS_ORIGINS` | string | http://localhost:5000 | Comma-separated CORS origins |
| `UPLOAD_DIR` | string | uploads | Directory for file uploads |
| `UPLOAD_DIR` | string | uploads | Directory for file uploads (resolved against the project root) |
| `MAX_UPLOAD_SIZE_MB` | int | 50 | Maximum upload file size in MB |
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute |
| `RATE_LIMIT_GENERAL` | int | 100 | General requests per minute |
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute, per real client IP |
| `RATE_LIMIT_GENERAL` | int | 300 | General requests per minute, per real client IP (post-V2.0.0; was 100 in V1.0.x) |
| `STATION_CODE` | string | (empty) | **Per-tablet** code identifying the station this Flask client serves. Must match a station created in the admin UI. Empty = the client refuses `/measure/select` with HTTP 503 "Stazione non configurata". |
| `SSL_CERTFILE` | string | (empty) | Path to SSL certificate (production) |
| `SSL_KEYFILE` | string | (empty) | Path to SSL private key (production) |
+89 -3
View File
@@ -37,6 +37,8 @@ TieMeasureFlow is a web-based measurement management system that enables teams t
| **Subtask** | Individual measurement point with tolerance limits (UTL/UWL/LWL/LTL) |
| **Measurement** | Individual recorded value with automatic pass/fail/warning status |
| **Lot/Serial** | Traceability fields linking measurements to physical parts |
| **Station** | A physical measurement post (typically one tablet on a production line). Each station has a unique code (`STATION_CODE`) and a list of assigned recipes |
| **Station assignment** | Many-to-many link between a station and the recipes available to the operators using that station's tablet |
### Architecture
@@ -175,23 +177,25 @@ TieMeasureFlow has four primary roles. Users can have multiple roles simultaneou
### Admin
**Purpose:** System administration and user management
**Purpose:** System administration, user management and station deployment
**Permissions:**
- Create/edit/delete users
- Assign roles to users
- Regenerate user API keys
- Create/edit/delete stations and assign recipes to them
- Configure system settings
- Upload company logo
- Manage CSV export settings
**Access:**
- Menu: **Admin** → User Management
- Can see: All users and system settings
- Menu: **Admin** → Utenti / Stazioni
- Can see: All users, all stations and system settings
**Typical Tasks:**
- Onboard new users
- Reset lost API keys
- Roll out a new tablet: create the matching station, assign recipes, hand the `STATION_CODE` to devops
- Configure locale/format settings
- Upload company branding
- Manage system access
@@ -276,6 +280,12 @@ When you edit a recipe (add/remove tasks, change tolerances), a **new version**
## MeasurementTec Workflow
### Recipes you see are filtered by station
Each tablet/PC running the Flask client is configured at deployment time with a `STATION_CODE` environment variable (for example `ST-LINEA-A`). Whenever you open **Select Recipe**, the page only lists recipes that the admin has assigned to that station. If your tablet shows fewer recipes than you expect, ask the admin to assign the missing recipes to your station — see **Admin Workflow → Station Management**.
If the page shows "Stazione non configurata" (HTTP 503), the deployment is missing the `STATION_CODE` setting; this is a deploy-time configuration issue, not something the operator can fix from the UI.
### Select a Recipe
**Method 1: Search**
@@ -503,6 +513,82 @@ If user loses their API key:
4. New key is generated and displayed
5. Provide new key to user (display once only)
### Station Management
Stations are how TieMeasureFlow enforces "this tablet is responsible for these measurements". Each physical measurement post in the shop floor is modelled as a station; each tablet's Flask client identifies itself through a `STATION_CODE` env var that must match a station's code in the database.
The page is reachable from the navbar entry **"Stazioni"** (workstation icon) for any user with the admin flag, or directly at `/admin/stations`.
#### Mental model
- **One station = one tablet/PC** in the shop floor. The station's identity (`STATION_CODE`) is configured **once at deploy time** in the tablet's `.env`, never changed at runtime.
- A station has a list of **assigned recipes**. The operator using that tablet sees exactly that list — no more, no less.
- A recipe can be assigned to several stations (e.g. a calibration recipe everyone needs).
- A station can be temporarily **disabled** (`active = false`) to take a line offline without losing its history; tablets pointing at a disabled station get HTTP 404 from the recipe endpoint.
#### Create a Station
1. Navigate to **Admin****Stazioni**
2. Click **Nuova Stazione**
3. Fill in the modal:
- **Codice** (required, unique): `ST-LINEA-A`. This is the value the tablet will set as `STATION_CODE` in its `.env`. Use ASCII letters, digits and hyphens only — no spaces, no accented characters. Once created the code cannot be changed (changing it would break every tablet pointing at it).
- **Nome** (required): human-readable description, e.g. `Linea A — Tornitura alberi`.
- **Postazione** (optional): physical location, e.g. `Reparto 2 — Cella 3`.
- **Note** (optional): free text, useful for tracking who set up the station.
- **Attiva** (default checked): uncheck only when retiring the station.
4. Click **Crea Stazione**
Naming convention: prefix every station code with `ST-` and use a stable identifier that survives shop-floor reorganisations.
#### Assign Recipes to a Station
1. From the stations table, click the **checklist icon** on the row of the target station
2. The "Ricette Assegnate" modal opens with two columns:
- **Ricette disponibili** (left): every recipe in the system not yet assigned to this station. Each row has an inline **+ Assegna** button that immediately moves the recipe to the right column.
- **Assegnate alla stazione** (right): the list the operator at this station's tablet will see. The **X** button removes the assignment.
3. Use the search field at the top to narrow either column by recipe code or name
4. Empty-state hints tell you why a column is empty:
- "Tutte le ricette sono già assegnate" — nothing more to assign
- "Nessuna ricetta nel sistema" — create at least one recipe first (Maker workflow)
- "Nessun risultato per il filtro" — clear the search
The assignment audit trail (`assigned_by`, `assigned_at`) is stored on the server but not currently shown in the UI.
#### Edit a Station
1. Click the **pencil icon** (or the row itself) for the target station
2. Update **Nome**, **Postazione**, **Note** or the **Attiva** flag
3. Click **Salva Modifiche**
The **Codice** field is read-only on edit because the value is contractually bound to the `STATION_CODE` set on already-deployed tablets. To rename a station you must delete it and create a new one with the new code, then update every affected tablet's `.env`.
#### Delete a Station
1. Click the **trash icon** for the station
2. Confirm in the modal
Deletion cascades to **all recipe assignments** for that station. Existing measurements collected by tablets at that station are not affected (measurements are linked to recipe versions, not stations).
#### The `ST-DEFAULT` station
The first time `/api/setup/seed` runs, it creates a station called `ST-DEFAULT` and assigns every demo recipe to it. This is intended for single-tablet demos and dev setups where no per-station segmentation is needed.
In multi-station production deployments you should either:
- Delete `ST-DEFAULT` once your real stations are configured, or
- Disable it (`Attiva` off), to prevent a misconfigured tablet from accidentally inheriting the default assignment set.
#### Tablet deployment cheat sheet
For each new physical tablet:
1. Admin creates the station in the UI (e.g. `ST-LINEA-A`)
2. Admin assigns the relevant recipes
3. Devops sets `STATION_CODE=ST-LINEA-A` in the tablet's `.env`
4. Tablet container starts; the operator opens **Measure → Select Recipe** and sees the curated list
If step 3 is missed, **Select Recipe** shows the page "Stazione non configurata" — this is the intentional fail-fast behaviour to prevent a tablet from silently falling back to the wrong recipe set.
### System Settings
#### Configure CSV Export
+2
View File
@@ -57,10 +57,12 @@ Da: master plan §0 "Precondizioni e Decisioni Aperte". Da risolvere col cliente
|---|---|---|
| 3 test backend pre-esistenti rotti (`test_recipes`, `test_tasks`) | Media | Investigare prima di Fase 3 (toccano recipe + task router). |
| 1 test client pre-esistente rotto (`test_save_measurement_proxy`) | Bassa | Probabilmente CSRF/payload. Risolvere con Fase 4. |
| Pagina `task_complete` riepilogo: utente segnala riga vuota in alcuni scenari | Media | Da debuggare (rendering corretto via curl ma utente vede vuoto in browser, possibile interazione con sessione lot/serial). |
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato (impatto deploy). |
| Header `X-API-Key` rename a `X-Api-Key` | Bassa | Vedere se M2 lo richiede. |
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 in M2. |
| `Dockerfile.frontend`: `pybabel compile` via `uv run` non testato in build reale | Alta | Verificare al primo `docker compose build`. |
| Smoke test in container Docker (non solo locale uvicorn+gunicorn) | Alta | Validare che i Dockerfile riscritti con `uv` buildino e girino correttamente prima di chiudere V2.0.0. |
## Open per scelta utente prima della prossima sessione
+29 -6
View File
@@ -1,6 +1,6 @@
# Stato Progetto TieMeasureFlow — V2.0.0
> Snapshot al 2026-04-25. Aggiornare ad ogni milestone.
> Snapshot al 2026-04-27. Aggiornare ad ogni milestone.
## Versione corrente
@@ -56,6 +56,19 @@ Il sistema base (V1.0.7) è completo e collaudato: ricette, task, misurazioni, S
- Alembic env.py aggiunge project root a `sys.path`; `script_location = %(here)s` resta valido.
- `.dockerignore` aggiornato.
### Hardening post-restructure (smoke test 2026-04-26)
Sequenza di smoke test in locale (uvicorn + gunicorn + MySQL Docker) ha fatto emergere quattro regressioni che sarebbero rimaste invisibili al test suite:
- **`src/backend/config.py`**: `env_file` era cwd-relative (`../../.env`). Rotto fuori da `src/backend/`. Risolto con percorso assoluto `Path(__file__).resolve().parents[2] / ".env"`.
- **`src/backend/models/orm/__init__.py`**: `Station` e `StationRecipeAssignment` non erano esportati, quindi `Base.metadata.create_all` non creava le tabelle stations. Aggiunti agli import.
- **`.env.example`**: `UPLOAD_DIR=server/uploads` era residuo della vecchia struttura → file landavano fuori dall'albero di progetto. Aggiornato a `UPLOAD_DIR=uploads`.
- **Apostrofi italiani in template Alpine** (`l'utente`, `nell'eliminazione`, `nell'assegnazione`): chiudevano prematuramente JS string literals dentro `x-text` e blocchi `<script>`. Riscritti con delimitatori `&quot;...&quot;` o riformulazione testuale.
Inoltre **UX rework** della modale assegnazione ricette su `/admin/stations`: dropdown sostituita da layout a 2 colonne (disponibili / assegnate) con bottone inline `+ Assegna`, search filter, empty state esplicativo (mostrava silenziosamente lista vuota se tutte le ricette erano già assegnate).
Test guard aggiunto: `test_template_js_syntax.py` valida ogni inline `<script>` E ogni espressione Alpine (`x-*`, `@*`, `:*`) della pagina con `node --check`. Cattura automaticamente il bug-class apostrofo. Skip se Node non è installato.
## Layout repository (V2.0.0)
```
@@ -90,13 +103,23 @@ TieMeasureFlow/
└── tests/
```
## Test status
## Smoke test status
| Suite | Pass | Fail | Note |
|---|---|---|---|
Validazione end-to-end in locale (2026-04-26):
- ✅ MySQL container Docker up, schema creato, alembic stamp head OK
- ✅ uvicorn `--reload` su :8000, `/api/health` risponde
- ✅ Seed `/api/setup/seed` con `SETUP_PASSWORD=adriano77` → admin + 4 utenti demo + DEMO-001 + ST-DEFAULT con assegnazione automatica
- ✅ Login `admin/admin123` via web, sessione persistente
-`/admin/stations`: tabella, modal create/edit, modal gestione assegnazioni a 2 colonne con search, eliminazione con cascade
-`/admin/users`, `/maker/recipes`, `/measure/select` (filtrato per stazione), `/statistics/dashboard`
- ✅ Workflow MeasurementTec end-to-end: select_recipe → task_list → task_execute → task_complete (riepilogo con misure)
- ✅ Hot reload Flask + uvicorn `--reload` + Tailwind watch attivi durante lo sviluppo
## Test status
| Backend (`src/backend/tests/`) | 127 | 3 | Fail pre-esistenti: `test_recipes` (2) + `test_tasks` (1). Nessuno introdotto dalla V2.0.0. |
| Frontend (`src/frontend/flask_app/tests/`) | 44 | 1 | Fail pre-esistente: `test_save_measurement_proxy`. |
| **Totale** | **171** | **4** | Tutti i fallimenti tracciati come tech debt da risolvere. |
| Frontend (`src/frontend/flask_app/tests/`) | 46 | 1 | +2 test post-restructure (`test_template_js_syntax.py`). Fail pre-esistente: `test_save_measurement_proxy`. |
| **Totale** | **173** | **4** | Tutti i fallimenti tracciati come tech debt da risolvere. |
## Stack confermato
+9 -2
View File
@@ -97,10 +97,17 @@ async def get_measurements(
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=500),
user: User = Depends(require_metrologist),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Query measurements with filters and pagination."""
"""Query measurements with filters and pagination.
Available to any authenticated user. The MeasurementTec workflow needs
this endpoint to render `task_complete.html` after running through a
recipe; the Metrologist dashboard uses the same endpoint with broader
filters. Measurements carry no PII beyond numeric values + a recipe
reference, so role-gating beyond authentication isn't justified.
"""
# Build filter conditions
filters = []
if recipe_id is not None:
+7 -2
View File
@@ -57,8 +57,13 @@ class Settings(BaseSettings):
# Path(__file__) = src/backend/config.py → parents[2] = project root
return Path(__file__).resolve().parents[2] / self.upload_dir
# ../../.env reaches the project root from src/backend/.
model_config = {"env_file": "../../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
# Always resolve .env against the project root regardless of cwd
# (pydantic-settings would otherwise treat the path as cwd-relative).
model_config = {
"env_file": str(Path(__file__).resolve().parents[2] / ".env"),
"env_file_encoding": "utf-8",
"extra": "ignore",
}
settings = Settings()
+3
View File
@@ -5,6 +5,7 @@ from src.backend.models.orm.task import RecipeTask, RecipeSubtask
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.access_log import AccessLog
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
from src.backend.models.orm.station import Station, StationRecipeAssignment
__all__ = [
"User",
@@ -16,4 +17,6 @@ __all__ = [
"AccessLog",
"SystemSetting",
"RecipeVersionAudit",
"Station",
"StationRecipeAssignment",
]
+37
View File
@@ -211,6 +211,43 @@ class TestListMeasurements:
assert "total" in data
assert data["total"] >= 3
async def test_measurement_tec_can_list_measurements(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Regression: MeasurementTec must be able to list measurements.
The Flask client renders task_complete.html by calling GET
/api/measurements?version_id=X right after the operator finishes a
recipe. The endpoint used to require Metrologist, which silently
produced an empty riepilogo for every operator without that role.
"""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
for val in [9.8, 10.0, 10.2]:
await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": val,
},
)
resp = await client.get(
f"/api/measurements/?version_id={version_id}",
headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["total"] >= 3
async def test_measurement_by_recipe_filter(
self,
client: AsyncClient,
@@ -221,7 +221,7 @@
<div class="absolute inset-0 bg-black/50" @click="closeAssignmentsModal()"></div>
<div x-show="showAssignments"
x-transition
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
<div>
@@ -236,55 +236,86 @@
</div>
<div class="px-6 py-4 space-y-4">
<!-- Add recipe -->
<div class="flex items-end gap-2">
<div class="flex-1">
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Aggiungi ricetta') }}</label>
<select x-model="recipeToAdd"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
<option value="">{{ _('-- Seleziona ricetta --') }}</option>
<template x-for="r in unassignedRecipes" :key="r.id">
<option :value="r.id" x-text="r.code + ' — ' + r.name"></option>
</template>
</select>
</div>
<button @click="assignRecipe()"
:disabled="!recipeToAdd || saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
{{ _('Assegna') }}
</button>
<!-- Search filter -->
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" x-model="recipeSearch"
placeholder="{{ _('Filtra per codice o nome ricetta...') }}"
class="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
</div>
<!-- Assigned list -->
<div>
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2">
{{ _('Ricette correntemente assegnate') }} (<span x-text="assignedRecipes.length"></span>)
</h3>
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
<template x-for="r in assignedRecipes" :key="r.id">
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
<div>
<span class="font-mono text-xs font-semibold text-[var(--text-primary)]" x-text="r.code"></span>
<span class="ml-2 text-sm text-[var(--text-secondary)]" x-text="r.name"></span>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Available recipes (left column) -->
<div>
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _('Ricette disponibili') }} (<span x-text="filteredUnassignedRecipes.length"></span>)
</h3>
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
<template x-for="r in filteredUnassignedRecipes" :key="r.id">
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
<div class="min-w-0 flex-1">
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
</div>
<button @click="assignRecipe(r.id)"
:disabled="saving"
class="ml-2 inline-flex items-center gap-1 px-2.5 py-1 bg-primary text-white text-xs font-medium rounded-md
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50"
:title="'{{ _('Assegna a questa stazione') }}'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Assegna') }}
</button>
</div>
<button @click="unassignRecipe(r.id)"
:disabled="saving"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
:title="'{{ _('Rimuovi') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<template x-if="assignedRecipes.length === 0">
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]">
{{ _('Nessuna ricetta assegnata') }}
</div>
</template>
</template>
<template x-if="filteredUnassignedRecipes.length === 0">
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]"
x-text="unassignedEmptyMessage"></div>
</template>
</div>
</div>
<!-- Assigned recipes (right column) -->
<div>
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
{{ _('Assegnate alla stazione') }} (<span x-text="filteredAssignedRecipes.length"></span>)
</h3>
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
<template x-for="r in filteredAssignedRecipes" :key="r.id">
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
<div class="min-w-0 flex-1">
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
</div>
<button @click="unassignRecipe(r.id)"
:disabled="saving"
class="ml-2 p-1.5 rounded-md text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
:title="'{{ _('Rimuovi assegnazione') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<template x-if="filteredAssignedRecipes.length === 0">
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]"
x-text="assignedEmptyMessage"></div>
</template>
</div>
</div>
</div>
<template x-if="errorMsg">
@@ -351,7 +382,7 @@ function stationManagement(initialStations, initialRecipes) {
deleteTarget: null,
assignmentStation: null,
assignedRecipes: [],
recipeToAdd: '',
recipeSearch: '',
form: {
code: '',
name: '',
@@ -380,6 +411,32 @@ function stationManagement(initialStations, initialRecipes) {
return this.allRecipes.filter(r => !assignedIds.has(r.id) && r.active !== false);
},
_matchesRecipeSearch(r) {
if (!this.recipeSearch.trim()) return true;
const q = this.recipeSearch.toLowerCase();
return (r.code && r.code.toLowerCase().includes(q)) ||
(r.name && r.name.toLowerCase().includes(q));
},
get filteredUnassignedRecipes() {
return this.unassignedRecipes.filter(r => this._matchesRecipeSearch(r));
},
get filteredAssignedRecipes() {
return this.assignedRecipes.filter(r => this._matchesRecipeSearch(r));
},
get unassignedEmptyMessage() {
if (this.recipeSearch) return '{{ _("Nessun risultato per il filtro") }}';
if (this.allRecipes.length === 0) return '{{ _("Nessuna ricetta nel sistema") }}';
return '{{ _("Tutte le ricette sono già assegnate") }}';
},
get assignedEmptyMessage() {
if (this.recipeSearch) return '{{ _("Nessun risultato per il filtro") }}';
return '{{ _("Nessuna ricetta assegnata") }}';
},
openCreateModal() {
this.isEditing = false;
this.editingId = null;
@@ -477,7 +534,7 @@ function stationManagement(initialStations, initialRecipes) {
});
if (!resp.ok) {
const result = await resp.json().catch(() => ({}));
alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');
alert(result.detail || "{{ _('Errore nella eliminazione') }}");
return;
}
this.stations = this.stations.filter(s => s.id !== this.deleteTarget.id);
@@ -493,7 +550,7 @@ function stationManagement(initialStations, initialRecipes) {
async openAssignmentsModal(station) {
this.assignmentStation = station;
this.assignedRecipes = [];
this.recipeToAdd = '';
this.recipeSearch = '';
this.errorMsg = '';
this.showAssignments = true;
try {
@@ -513,27 +570,28 @@ function stationManagement(initialStations, initialRecipes) {
this.showAssignments = false;
this.assignmentStation = null;
this.assignedRecipes = [];
this.recipeSearch = '';
this.errorMsg = '';
},
async assignRecipe() {
if (!this.recipeToAdd || !this.assignmentStation) return;
async assignRecipe(recipeId) {
if (!recipeId || !this.assignmentStation) return;
const id = parseInt(recipeId, 10);
this.saving = true;
this.errorMsg = '';
try {
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ recipe_id: parseInt(this.recipeToAdd, 10) }),
body: JSON.stringify({ recipe_id: id }),
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || '{{ _("Errore nell\'assegnazione") }}';
this.errorMsg = result.detail || "{{ _('Errore nella assegnazione') }}";
return;
}
const recipe = this.allRecipes.find(r => r.id === parseInt(this.recipeToAdd, 10));
const recipe = this.allRecipes.find(r => r.id === id);
if (recipe) this.assignedRecipes.push({ id: recipe.id, code: recipe.code, name: recipe.name, active: recipe.active });
this.recipeToAdd = '';
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
@@ -307,8 +307,8 @@
x-text="toggleUser?.active ? '{{ _('Conferma Disattivazione') }}' : '{{ _('Conferma Riattivazione') }}'"></h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
<span x-text="toggleUser?.active
? '{{ _('Sei sicuro di voler disattivare l\'utente') }}'
: '{{ _('Sei sicuro di voler riattivare l\'utente') }}'"></span>
? &quot;{{ _('Sei sicuro di voler disattivare l\'utente') }}&quot;
: &quot;{{ _('Sei sicuro di voler riattivare l\'utente') }}&quot;"></span>
<strong x-text="toggleUser?.username"></strong>?
</p>
<div class="flex justify-end gap-3">
@@ -175,7 +175,9 @@
</tr>
</thead>
<tbody>
{% for m in measurements|sort(attribute='task_info.order_index,subtask.marker_number') %}
{# Jinja's sort takes a single attribute. Chain stable sorts
to get task.order_index → subtask.marker_number ordering. #}
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
<tr>
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
@@ -0,0 +1,194 @@
"""Verify that inline <script> blocks in admin templates parse as valid JS.
We had a regression where an Italian translation containing an apostrophe
(e.g. "Errore nell'eliminazione") broke a single-quoted JS string literal,
killing every Alpine binding on the page (the "Nuova Stazione" button no
longer triggered openCreateModal).
Strategy: render each admin page through the Flask test client, extract every
inline <script> body and feed it to `node --check` for a syntax verdict. We
force the IT locale so the worst-case translations (which contain apostrophes)
are the ones evaluated.
The test is skipped if `node` is not on PATH; CI images that exclude Node
get a soft skip rather than a false failure.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
from unittest.mock import MagicMock, patch
import pytest
# Match every inline <script>…</script> (i.e. without `src=`) so we don't try
# to syntax-check Alpine.js / Plotly bundles served from a CDN.
_INLINE_SCRIPT_RX = re.compile(
r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>",
re.DOTALL | re.IGNORECASE,
)
# Match every Alpine.js / event-binding attribute on a tag — the bug class we
# care about (apostrophe in IT translation closing a JS literal) hides equally
# well inside x-text="…" as inside <script>…</script>.
#
# Covers: x-data, x-init, x-text, x-html, x-show, x-if, x-for, x-bind:foo,
# x-on:foo, x-model, x-effect, x-transition:*, plus the @evt and :attr
# shortcuts.
_ALPINE_ATTR_RX = re.compile(
r"""(?P<name>(?:x-[a-zA-Z][a-zA-Z0-9:-]*|@[a-zA-Z][a-zA-Z0-9.:-]*|:[a-zA-Z][a-zA-Z0-9.:-]*))="(?P<value>[^"]*)\"""",
re.DOTALL,
)
# Skip these — they're plain identifiers / object shapes, not JS expressions
# that node --check should reject for things like a stray apostrophe.
_ALPINE_NON_EXPR_NAMES = {
# transition modifiers like x-transition:enter-start get bare class names
"x-transition:enter",
"x-transition:enter-start",
"x-transition:enter-end",
"x-transition:leave",
"x-transition:leave-start",
"x-transition:leave-end",
# x-cloak has no value, x-data is parsed below as an expression but Alpine
# also accepts an object literal.
}
def _alpine_attribute_expressions(html: str):
"""Yield (attr_name, value) tuples for every Alpine expression attribute."""
for m in _ALPINE_ATTR_RX.finditer(html):
name = m.group("name")
if name in _ALPINE_NON_EXPR_NAMES:
continue
value = m.group("value")
if not value.strip():
continue
# HTML entities used to embed quotes inside the attribute value
# (most common: &quot; for ").
value = (
value.replace("&quot;", '"')
.replace("&#34;", '"')
.replace("&#39;", "'")
.replace("&amp;", "&")
)
yield name, value
def _node_check(source: str, label: str) -> None:
"""Fail the test if `node --check` rejects `source`."""
if not source.strip():
return
node = shutil.which("node")
if node is None:
pytest.skip("node binary not found on PATH; cannot validate JS syntax")
fd, path = tempfile.mkstemp(suffix=".js")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(source)
result = subprocess.run(
[node, "--check", path],
capture_output=True,
text=True,
timeout=10,
)
finally:
os.unlink(path)
if result.returncode != 0:
snippet = source.strip()
if len(snippet) > 600:
snippet = snippet[:600] + "\n…(truncated)…"
pytest.fail(
f"{label}: node rejected this code as invalid JS.\n"
f"--- node stderr ---\n{result.stderr.strip()}\n"
f"--- source (first 600 chars) ---\n{snippet}"
)
def _check_alpine_attributes(html: str, page_label: str) -> None:
"""Validate every Alpine expression attribute on the page.
Wraps each value in `void (…)` so node parses it as an expression rather
than a statement. Function-body forms like `async () => { … }` parse fine
inside that wrapper too.
"""
for name, value in _alpine_attribute_expressions(html):
# Some Alpine attrs accept a function call shorthand (e.g.
# x-data="myComponent(window.__x)"); those parse fine as expressions.
wrapper = f"void ({value});\n"
_node_check(
wrapper,
f"{page_label} attribute {name}=\"\" did not parse as JS",
)
def _force_italian(client) -> None:
"""Make the next request render with the IT locale (worst case for JS)."""
with client.session_transaction() as sess:
sess["language"] = "it"
@pytest.fixture
def mock_admin_api():
"""Patch api_client used inside the admin blueprint."""
mock = MagicMock()
with patch("blueprints.admin.api_client", mock):
yield mock
def test_admin_stations_inline_js_is_valid(logged_in_client, mock_admin_api):
"""The /admin/stations page must emit syntactically valid inline JS in IT.
Regression guard for the apostrophe-in-single-quote bug.
"""
mock_admin_api.get.side_effect = [
# GET /api/stations
[{
"id": 1, "code": "ST-DEFAULT", "name": "Default Station",
"location": None, "notes": None, "active": True,
"created_by": 1, "created_at": "2026-04-25T10:00:00",
}],
# GET /api/recipes
[{"id": 1, "code": "DEMO-001", "name": "Demo Recipe", "active": True}],
]
_force_italian(logged_in_client)
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200, resp.data[:300]
html = resp.data.decode("utf-8")
scripts = _INLINE_SCRIPT_RX.findall(html)
assert scripts, "expected at least one inline <script> on /admin/stations"
for i, body in enumerate(scripts):
_node_check(body, f"/admin/stations script[{i}]")
_check_alpine_attributes(html, "/admin/stations")
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
"""Same guard for /admin/users (reuses the userManagement Alpine pattern)."""
mock_admin_api.get.return_value = [{
"id": 1, "username": "admin", "display_name": "Admin",
"email": None, "roles": ["Maker"], "is_admin": True,
"language_pref": "it", "theme_pref": "light", "active": True,
}]
_force_italian(logged_in_client)
resp = logged_in_client.get("/admin/users")
assert resp.status_code == 200
html = resp.data.decode("utf-8")
scripts = _INLINE_SCRIPT_RX.findall(html)
assert scripts
for i, body in enumerate(scripts):
_node_check(body, f"/admin/users script[{i}]")
_check_alpine_attributes(html, "/admin/users")