Compare commits
8 Commits
1a0431366f
...
V2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 85a00dea1b | |||
| e4eb4cd932 | |||
| 4de7d78b66 | |||
| 2a2d40bec9 | |||
| 6e284b0c0c | |||
| 742cc1fb58 | |||
| 563b7789f4 | |||
| e4b29c0b2d |
+2
-6
@@ -28,14 +28,10 @@ htmlcov/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Local-only notes (Markdown docs are still copied if a Dockerfile asks)
|
||||
PIANO_IMPLEMENTAZIONE.md
|
||||
# Local-only notes
|
||||
docs/
|
||||
*.docx
|
||||
|
||||
# Legacy Dockerfiles preserved in tree but not needed in builds
|
||||
src/backend/Dockerfile.legacy
|
||||
src/frontend/flask_app/Dockerfile.legacy
|
||||
|
||||
# Uploads are a runtime volume, never baked in.
|
||||
uploads/
|
||||
|
||||
|
||||
+4
-1
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# TieMeasureFlow by Tielogic
|
||||
|
||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate.
|
||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate, identità per-stazione e rate limiting per-tablet.
|
||||
|
||||
> **Versione corrente:** V2.0.0 (in sviluppo) — branch default `V2.0.0`.
|
||||
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -14,8 +17,10 @@ Caratteristiche principali:
|
||||
- Calcolo pass/fail/warning su quattro limiti di tolleranza (UTL, UWL, LWL, LTL)
|
||||
- SPC: Cp, Cpk, Pp, Ppk, control chart UCL/LCL, istogramma con curva normale — puro stdlib (no numpy)
|
||||
- Annotazioni grafiche su disegni tecnici (Fabric.js) con viewer sincronizzato all'esecuzione
|
||||
- Identità per-stazione (`STATION_CODE`): ogni tablet vede solo le ricette assegnate alla propria stazione, gestione assegnazioni via GUI admin
|
||||
- Interfaccia completamente localizzata IT/EN, dark mode, ottimizzata per tablet touch
|
||||
- Autenticazione API Key, rate limiting sliding window, audit log persistente
|
||||
- Autenticazione API Key, rate limiting sliding window per-IP reale (X-Forwarded-For-aware), audit log persistente
|
||||
- Capacità testata per ~20 tablet contemporanei (gunicorn 5 workers × 4 thread + uvicorn 4 workers async)
|
||||
|
||||
---
|
||||
|
||||
@@ -26,25 +31,25 @@ Browser/Tablet
|
||||
|
|
||||
Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione)
|
||||
|
|
||||
Flask Client :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||
| X-API-Key
|
||||
FastAPI Server :8000 (API REST asincrona)
|
||||
Flask Frontend :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||
| X-API-Key + X-Forwarded-For
|
||||
FastAPI Backend :8000 (API REST asincrona)
|
||||
|
|
||||
MySQL 8.0
|
||||
```
|
||||
|
||||
Il client Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask.
|
||||
Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask. L'IP reale del tablet è propagato in `X-Forwarded-For` per il rate limiter.
|
||||
|
||||
---
|
||||
|
||||
## Stack Tecnologico
|
||||
|
||||
### Backend (server/)
|
||||
### Backend (`src/backend/`)
|
||||
|
||||
| Componente | Versione | Ruolo |
|
||||
|---|---|---|
|
||||
| FastAPI | ultima stabile | Framework API REST asincrono |
|
||||
| SQLAlchemy 2.0 | async | ORM con pool connessioni |
|
||||
| SQLAlchemy 2.0 | async | ORM con pool connessioni 10+20 |
|
||||
| asyncmy | ultima stabile | Driver MySQL asincrono |
|
||||
| MySQL | 8.0 | Database relazionale |
|
||||
| Alembic | ultima stabile | Migrazioni schema |
|
||||
@@ -54,35 +59,47 @@ Il client Flask non espone mai le credenziali al browser: ogni chiamata al backe
|
||||
| bcrypt | ultima stabile | Hashing password |
|
||||
| Pillow | ultima stabile | Thumbnail automatici upload |
|
||||
|
||||
### Frontend (client/)
|
||||
### Frontend (`src/frontend/flask_app/`)
|
||||
|
||||
| Componente | Versione | Ruolo |
|
||||
|---|---|---|
|
||||
| Flask | 3.x | Framework web server-side |
|
||||
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
||||
| Jinja2 | incluso in Flask | Template engine |
|
||||
| Alpine.js | 3.x (CDN) | Reattivita leggera lato client |
|
||||
| Alpine.js | 3.x (CDN) | Reattività leggera lato client |
|
||||
| TailwindCSS | 3.x | CSS utility-first |
|
||||
| Plotly.js | CDN | Grafici SPC interattivi |
|
||||
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
||||
| html5-qrcode | CDN | Scanner barcode/QR camera |
|
||||
| Flask-Babel | ultima stabile | i18n IT/EN |
|
||||
|
||||
### Tooling
|
||||
|
||||
| Componente | Ruolo |
|
||||
|---|---|
|
||||
| **uv** | Package manager Python (no `requirements.txt`) |
|
||||
| `pyproject.toml` | Dipendenze monorepo con extra `server`/`client`/`dev` |
|
||||
| `uv.lock` | Lockfile per build riproducibili |
|
||||
| `.python-version` | Pin Python 3.11 |
|
||||
| pytest + pytest-asyncio + httpx + aiosqlite | Test stack |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start con Docker
|
||||
|
||||
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni e configurazione Nginx in un solo comando.
|
||||
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni, build delle immagini con `uv` e configurazione Nginx in un solo comando.
|
||||
|
||||
```bash
|
||||
# 1. Clona il repository
|
||||
git clone <repository-url>
|
||||
git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
||||
cd TieMeasureFlow
|
||||
|
||||
# 2. Configura le variabili d'ambiente
|
||||
cp .env.example .env
|
||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD
|
||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
|
||||
|
||||
# 3. Avvia i servizi (ambiente di sviluppo)
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
# 4. Verifica lo stato dei container
|
||||
docker compose -f docker-compose.dev.yml ps
|
||||
@@ -90,6 +107,7 @@ docker compose -f docker-compose.dev.yml ps
|
||||
# 5. Setup iniziale (solo al primo avvio)
|
||||
# Apri http://localhost/api/setup nel browser
|
||||
# Usa SETUP_PASSWORD configurata in .env
|
||||
# Lo script seed crea anche la stazione ST-DEFAULT con tutte le ricette assegnate
|
||||
```
|
||||
|
||||
L'applicazione sarà disponibile su:
|
||||
@@ -97,8 +115,9 @@ L'applicazione sarà disponibile su:
|
||||
- Frontend: http://localhost
|
||||
- API: http://localhost/api
|
||||
- Pagina setup: http://localhost/api/setup
|
||||
- Admin stazioni: http://localhost/admin/stations (solo `is_admin`)
|
||||
|
||||
Per il deployment in produzione (Traefik + SSL) consulta [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||
Per il deployment in produzione (Traefik + SSL) consulta [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -108,12 +127,14 @@ Dopo il primo avvio, la pagina `/api/setup` (protetta da `SETUP_PASSWORD`) perme
|
||||
|
||||
- **Initialize Database** — crea tutte le tabelle
|
||||
- **Create Admin User** — crea l'utente amministratore con credenziali da `.env`
|
||||
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio
|
||||
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio + crea stazione `ST-DEFAULT` con tutte le ricette assegnate
|
||||
- **Reset Database** — elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati)
|
||||
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
|
||||
|
||||
Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
||||
|
||||
Per gestire stazioni e assegnazioni ricette dopo il setup: `/admin/stations` (richiede login admin).
|
||||
|
||||
---
|
||||
|
||||
## Setup Manuale (Senza Docker)
|
||||
@@ -121,8 +142,9 @@ Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
||||
### Requisiti
|
||||
|
||||
- Python 3.11 o superiore
|
||||
- Node.js 18 o superiore
|
||||
- Node.js 18 o superiore (per TailwindCSS)
|
||||
- MySQL 8.0
|
||||
- [uv](https://docs.astral.sh/uv/) installato
|
||||
|
||||
### 1. Database MySQL
|
||||
|
||||
@@ -139,35 +161,37 @@ SQL
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY, CLIENT_SECRET_KEY, SETUP_PASSWORD
|
||||
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY,
|
||||
# CLIENT_SECRET_KEY, SETUP_PASSWORD, STATION_CODE
|
||||
```
|
||||
|
||||
### 3. Server FastAPI
|
||||
### 3. Installa dipendenze (uv)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
alembic -c migrations/alembic.ini upgrade head
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
uv sync --extra server --extra client --extra dev
|
||||
```
|
||||
|
||||
### 4. Client Flask
|
||||
### 4. Backend FastAPI
|
||||
|
||||
```bash
|
||||
cd client
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
pybabel compile -d translations
|
||||
flask run --host 0.0.0.0 --port 5000
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 5. TailwindCSS (watch per sviluppo)
|
||||
### 5. Frontend Flask
|
||||
|
||||
```bash
|
||||
cd client
|
||||
# Compila i cataloghi i18n una volta
|
||||
cd src/frontend/flask_app && uv run --project ../../.. pybabel compile -d translations && cd -
|
||||
|
||||
# Avvia (development)
|
||||
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
|
||||
```
|
||||
|
||||
### 6. TailwindCSS (watch per sviluppo)
|
||||
|
||||
```bash
|
||||
cd src/frontend/flask_app
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
```
|
||||
|
||||
@@ -185,9 +209,9 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
||||
| Ruolo | Descrizione |
|
||||
|---|---|
|
||||
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
|
||||
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail |
|
||||
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
|
||||
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
|
||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account |
|
||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,39 +219,53 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── server/ # FastAPI Backend
|
||||
│ ├── main.py # Entry point, lifespan, middleware, 10 router
|
||||
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
||||
│ ├── models/ # ORM: User, Recipe, RecipeVersion, RecipeTask,
|
||||
│ │ # RecipeSubtask, Measurement, AccessLog,
|
||||
│ │ # SystemSetting, RecipeVersionAudit
|
||||
│ ├── schemas/ # Pydantic v2 per validazione I/O API
|
||||
│ ├── routers/ # auth, users, recipes, tasks, measurements,
|
||||
│ │ # files, settings, statistics, reports, setup
|
||||
│ ├── services/ # recipe_service, measurement_service,
|
||||
│ │ # spc_service, report_service, auth_service
|
||||
│ ├── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||
│ └── templates/ # Template HTML pagina setup
|
||||
├── client/ # Flask Frontend
|
||||
│ ├── app.py # Factory pattern, CSRF, Babel
|
||||
│ ├── blueprints/ # auth, maker, measure, statistics, admin
|
||||
│ ├── services/ # APIClient singleton (proxy verso FastAPI)
|
||||
│ ├── templates/ # Jinja2 + Alpine.js
|
||||
│ ├── static/
|
||||
│ │ ├── css/ # TailwindCSS compilato
|
||||
│ │ └── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ │ # spc-charts, annotation-editor/viewer
|
||||
│ └── translations/ # Flask-Babel .po/.mo IT/EN
|
||||
├── nginx/ # Configurazione Nginx (dev)
|
||||
├── docs/ # Documentazione tecnica
|
||||
│ ├── API.md # Riferimento API REST
|
||||
│ ├── DEPLOYMENT.md # Guida deployment VPS (Traefik, SSL, DNS)
|
||||
│ └── USER_GUIDE.md # Manuale utente per ruolo
|
||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
||||
└── .env.example # Template variabili d'ambiente
|
||||
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||
├── uv.lock # Lockfile riproducibile
|
||||
├── .python-version # 3.11
|
||||
├── Dockerfile # Backend (uv + uvicorn)
|
||||
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||
├── nginx/ # Config Nginx (dev)
|
||||
├── uploads/ # Volume Docker file caricati
|
||||
├── docs/ # Documentazione (vedi indice docs/README.md)
|
||||
└── src/
|
||||
├── backend/ # FastAPI Backend
|
||||
│ ├── main.py # Entry point, lifespan, middleware, 11 router
|
||||
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
||||
│ ├── api/
|
||||
│ │ ├── routers/ # auth, users, recipes, tasks, measurements,
|
||||
│ │ │ # files, settings, statistics, reports,
|
||||
│ │ │ # setup, stations
|
||||
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── models/
|
||||
│ │ ├── orm/ # SQLAlchemy: User, Recipe, RecipeVersion,
|
||||
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
|
||||
│ │ │ # AccessLog, SystemSetting,
|
||||
│ │ │ # RecipeVersionAudit, Station,
|
||||
│ │ │ # StationRecipeAssignment
|
||||
│ │ └── api/ # Pydantic v2 schemas request/response
|
||||
│ ├── services/ # recipe_service, measurement_service,
|
||||
│ │ # spc_service, report_service,
|
||||
│ │ # auth_service, station_service
|
||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||
│ ├── templates/ # Pagina setup (Jinja2)
|
||||
│ └── tests/ # pytest + httpx + aiosqlite
|
||||
└── frontend/
|
||||
└── flask_app/ # Flask Frontend
|
||||
├── app.py # Factory + ProxyFix + CSRF + Babel
|
||||
├── config.py # STATION_CODE, API_SERVER_URL, ecc.
|
||||
├── compile_translations.py
|
||||
├── blueprints/ # auth, maker, measure, statistics, admin
|
||||
├── services/ # APIClient (proxy verso FastAPI con XFF)
|
||||
├── templates/ # Jinja2 + Alpine.js
|
||||
├── static/
|
||||
│ ├── css/ # TailwindCSS compilato
|
||||
│ └── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ # spc-charts, annotation-editor/viewer
|
||||
├── translations/ # Flask-Babel .po/.mo IT/EN
|
||||
└── tests/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -238,7 +276,7 @@ TieMeasureFlow/
|
||||
|
||||
| Comando | Descrizione |
|
||||
|---|---|
|
||||
| `docker compose -f docker-compose.dev.yml up -d` | Avvia servizi in sviluppo |
|
||||
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
||||
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
||||
| `docker compose logs -f server` | Segui log server in tempo reale |
|
||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||
@@ -248,47 +286,62 @@ TieMeasureFlow/
|
||||
|
||||
### Alembic (migrations)
|
||||
|
||||
Nota: `alembic.ini` si trova dentro `server/migrations/`, è richiesto il flag `-c`.
|
||||
`alembic.ini` si trova in `src/backend/migrations/`, è richiesto il flag `-c`. `env.py` aggiunge la project root a `sys.path` per risolvere `src.backend.*`.
|
||||
|
||||
```bash
|
||||
cd server
|
||||
alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
||||
alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
```
|
||||
|
||||
Via Docker:
|
||||
|
||||
```bash
|
||||
docker compose exec server alembic -c migrations/alembic.ini upgrade head
|
||||
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
```
|
||||
|
||||
### i18n (Traduzioni)
|
||||
|
||||
```bash
|
||||
cd client
|
||||
pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai stringhe
|
||||
pybabel update -i translations/messages.pot -d translations # Aggiorna catalogo
|
||||
pybabel compile -d translations # Compila .po → .mo
|
||||
cd src/frontend/flask_app
|
||||
uv run --project ../../.. pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai
|
||||
uv run --project ../../.. pybabel update -i translations/messages.pot -d translations # Aggiorna
|
||||
uv run --project ../../.. pybabel compile -d translations # Compila .po → .mo
|
||||
```
|
||||
|
||||
### Gestione dipendenze (uv)
|
||||
|
||||
```bash
|
||||
uv add <pacchetto> # core (entrambi)
|
||||
uv add --optional server <pacchetto> # solo backend
|
||||
uv add --optional client <pacchetto> # solo frontend
|
||||
uv add --optional dev <pacchetto> # solo dev/test
|
||||
uv sync --extra server --extra client --extra dev # reinstalla
|
||||
uv lock # rigenera uv.lock
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Il server usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
|
||||
Il backend usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
|
||||
|
||||
```bash
|
||||
# Server
|
||||
cd server && pytest # Tutti i test
|
||||
cd server && pytest tests/test_auth.py # Singolo modulo
|
||||
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
|
||||
cd server && pytest --cov # Con copertura
|
||||
# Tutti i test (backend + frontend)
|
||||
uv run pytest
|
||||
|
||||
# Client
|
||||
cd client && pytest
|
||||
# Solo backend
|
||||
uv run pytest src/backend/tests/
|
||||
uv run pytest src/backend/tests/test_auth.py
|
||||
uv run pytest src/backend/tests/test_auth.py::test_login_success
|
||||
uv run pytest --cov src/backend
|
||||
|
||||
# Solo frontend
|
||||
uv run pytest src/frontend/flask_app/tests/
|
||||
```
|
||||
|
||||
Stato corrente: **171 pass, 4 fail pre-esistenti** (vedi `docs/architecture/STATO_PROGETTO.md`).
|
||||
|
||||
---
|
||||
|
||||
## Variabili d'Ambiente
|
||||
@@ -304,8 +357,11 @@ Copia `.env.example` in `.env` e configura:
|
||||
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
|
||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, relativo a `server/`) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload |
|
||||
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||
| `RATE_LIMIT_GENERAL` | Richieste req/min/IP (default 300, per-tablet) |
|
||||
| `NGINX_PORT`, `NGINX_SSL_PORT` | Porte Nginx (solo compose dev) |
|
||||
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
|
||||
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
|
||||
@@ -314,16 +370,35 @@ Copia `.env.example` in `.env` e configura:
|
||||
|
||||
## Documentazione
|
||||
|
||||
Indice completo: [`docs/README.md`](docs/README.md).
|
||||
|
||||
### Stato e direzione
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [docs/API.md](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
||||
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
||||
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
||||
|
||||
### Riferimenti operativi
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [`docs/API.md`](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
||||
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
|
||||
|
||||
### Piani dettagliati
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [`docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md`](docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 (M1 + M2, decisioni aperte, stime) |
|
||||
| [`docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md`](docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD Fase 1 stazioni (completato) |
|
||||
|
||||
---
|
||||
|
||||
## Licenza
|
||||
|
||||
Proprietary - Tielogic. All rights reserved.
|
||||
Proprietary — Tielogic. All rights reserved.
|
||||
|
||||
Questo software è di proprietà esclusiva di Tielogic ed è protetto dalle leggi sul copyright. Non è consentita la distribuzione, modifica o utilizzo senza autorizzazione scritta.
|
||||
|
||||
+184
@@ -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
@@ -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) |
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ TieMeasureFlow supports complete internationalization (i18n) with:
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
client/
|
||||
src/frontend/flask_app/
|
||||
├── translations/ # Flask-Babel translations
|
||||
│ ├── babel.cfg # Extraction config
|
||||
│ ├── it/LC_MESSAGES/
|
||||
@@ -99,7 +99,7 @@ flash(_("Profilo aggiornato con successo"))
|
||||
After editing .po files:
|
||||
|
||||
```bash
|
||||
cd client
|
||||
cd src/frontend/flask_app
|
||||
python compile_translations.py
|
||||
```
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Documentazione TieMeasureFlow
|
||||
|
||||
Indice della documentazione del progetto.
|
||||
|
||||
## Stato e direzione
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V2.0.0). Snapshot del sistema, componenti e capacità. |
|
||||
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta da fare. Fasi 2-7 della migrazione rev04 verso V1.1.0/M1 demo cliente. |
|
||||
|
||||
## Riferimenti operativi
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`API.md`](API.md) | Riferimento endpoint REST esposti dal backend FastAPI. |
|
||||
| [`DEPLOYMENT.md`](DEPLOYMENT.md) | Guida deploy su VPS (Hostinger/Tielogic con Traefik + Let's Encrypt). |
|
||||
| [`USER_GUIDE.md`](USER_GUIDE.md) | Manuale utente (operatore, maker, metrologist, admin). |
|
||||
| [`I18N_SETUP.md`](I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js). |
|
||||
|
||||
## Piani dettagliati TDD (rev04)
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`superpowers/plans/2026-04-17-rev04-master-roadmap.md`](superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 V1.0.7 → V1.1.0, le 7 fasi e le decisioni aperte. |
|
||||
| [`superpowers/plans/2026-04-17-rev04-phase1-stations.md`](superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD dettagliato Fase 1 (stazioni e identità per-tablet). **COMPLETATO.** |
|
||||
|
||||
## Specifiche esterne (binari)
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`specs/2026-04-16-schema-sviluppo-rev04.docx`](specs/2026-04-16-schema-sviluppo-rev04.docx) | Schema sviluppo SW TieFlow rev04-2026 fornito dal committente. |
|
||||
|
||||
## Storico
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`archive/2026-02-06-piano-implementazione-v1.md`](archive/2026-02-06-piano-implementazione-v1.md) | Piano implementazione V1.0.0 originale (riferimento storico). |
|
||||
|
||||
## Ulteriori riferimenti nel repo
|
||||
|
||||
- [`/CLAUDE.md`](../CLAUDE.md) — guidance per Claude Code (architettura, comandi, pattern critici).
|
||||
- [`/README.md`](../README.md) — entry point del progetto.
|
||||
+89
-3
@@ -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
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Roadmap TieMeasureFlow — V2.0.0 → V1.1.0 (rev04 / M1 demo cliente)
|
||||
|
||||
> Aggiornare ad ogni Fase chiusa.
|
||||
|
||||
## Riferimenti
|
||||
|
||||
- Master plan dettagliato: [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md)
|
||||
- Spec sorgente: [`../specs/2026-04-16-schema-sviluppo-rev04.docx`](../specs/2026-04-16-schema-sviluppo-rev04.docx)
|
||||
- Stato corrente: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
|
||||
|
||||
## Strategia: due milestone
|
||||
|
||||
| Milestone | Scope | Obiettivo |
|
||||
|---|---|---|
|
||||
| **M1 — Demo cliente** | Fasi 1-5 + deploy "demo" | Sistema testabile end-to-end col cliente per raccogliere feedback |
|
||||
| **M2 — Produzione** | Fasi 6-7 + correzioni post-feedback + GAIA live | Rollout su tablet/PC reali |
|
||||
|
||||
## Stato Fasi (M1)
|
||||
|
||||
| Fase | Scope | Stato | Branch / Commit |
|
||||
|---|---|---|---|
|
||||
| **1** | Stazioni + identità per-tablet | ✅ **COMPLETATA** | `V2.0.0` (merge `ea8e468` da `feature/rev04-phase1-stations`) |
|
||||
| 2 | Ruolo Capoturno (Supervisor) + override token breve | ⏳ Da iniziare | — |
|
||||
| 3 | Editor ricetta a blocchi (preparation + measurement) | ⏳ Da iniziare | — |
|
||||
| 4 | Workflow operatore (retry/timer/autologout/avvio produzione) | ⏳ Da iniziare | — |
|
||||
| 5 (M1) | `ImportOnlyGaiaClient` + UI import dati cliente reali | ⏳ Da iniziare | — |
|
||||
| Deploy M1 | VPS demo (compose + Traefik + LE, no registry) | ⏳ Da iniziare | — |
|
||||
|
||||
## Stato Fasi (M2)
|
||||
|
||||
| Fase | Scope | Stato |
|
||||
|---|---|---|
|
||||
| 5 (M2) | GAIA reale (protocollo TBD, polling, comandi produzione) | ⏳ Bloccata da decisioni cliente (D-0.1, D-0.2) |
|
||||
| 6 | Deploy B industriale (registry privato + Watchtower + STATION_ID per-tablet + CI release) | ⏳ Pianificata |
|
||||
| 7 | Hardening, security review, E2E sito pilota, docs aggiornati, i18n delta | ⏳ Pianificata |
|
||||
|
||||
## Decisioni aperte (bloccanti per M2 / future fasi)
|
||||
|
||||
Da: master plan §0 "Precondizioni e Decisioni Aperte". Da risolvere col cliente prima della Fase 5/6.
|
||||
|
||||
| ID | Decisione | Stato | Bloccante per |
|
||||
|---|---|---|---|
|
||||
| D-0.1 | Protocollo integrazione GAIA (REST / DB shared / OPC-UA / file) | **Aperta** | Fase 5 reale (M2) |
|
||||
| D-0.2 | Credenziali e rete GAIA (VPN / firewall / whitelist IP) | **Aperta** | Fase 5 reale (M2) |
|
||||
| D-0.3 | Target hardware "tablet" (Windows / Linux industriale / Android) | **Aperta** | Fase 6 (deploy B) |
|
||||
| D-0.4 | Cicalino/luce avviso (audio HTML5 / hardware USB / entrambi) | **Rimandata a M2** | Fase 4 finale |
|
||||
| D-0.5 | Parametri runtime modificabili vs versione immutabile | **Aperta** (raccomandato B: separare volatili) | Fase 3 |
|
||||
| D-0.6 | Auth capoturno durante override (modale / PIN / RFID) | **Aperta** | Fase 2 |
|
||||
| D-0.7 | Timeout auto-logout | **Risolta** | — |
|
||||
| D-0.8 | Naming ruolo capoturno | **Proposta:** `Supervisor` | Fase 2 |
|
||||
| D-0.9 | Tag versione immagine docker | **Proposta:** SemVer + `latest` | Fase 6 |
|
||||
| D-0.10 | Registry esposto su Internet o solo VPN | **Proposta:** solo VPN cliente | Fase 6 |
|
||||
|
||||
## Tech debt da chiudere
|
||||
|
||||
| Item | Priorità | Note |
|
||||
|---|---|---|
|
||||
| 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
|
||||
|
||||
1. **Quale fase iniziare adesso?** Opzioni: Fase 2 (Supervisor — sblocca workflow), Fase 3 (block editor — independente), Fase 5 (import GAIA dati reali — sblocca demo).
|
||||
2. **Revisione decisioni aperte col cliente** — D-0.1 / D-0.2 / D-0.3 / D-0.6 prima di pianificare Fase 5 e 6.
|
||||
3. **Smoke test Docker** della nuova struttura V2.0.0 (`docker compose -f docker-compose.dev.yml up --build`) per validare i Dockerfile riscritti.
|
||||
4. **Test di carico** (k6/locust) a 20 VU su `/measure/save-measurement` per validare la scalatura worker (capacità annunciata: 20-30 tablet contemporanei).
|
||||
|
||||
## Stima tempi residui M1 (post-Fase 1)
|
||||
|
||||
| Task | Stima full-time |
|
||||
|---|---|
|
||||
| Fase 2 — Supervisor + override | 1 settimana |
|
||||
| Fase 3 — Block editor | 1.5 settimane |
|
||||
| Fase 4 — Workflow operatore | 2 settimane |
|
||||
| Fase 5 (M1) — Import-only GAIA | 1 settimana |
|
||||
| Deploy M1 demo | 0.5 settimane |
|
||||
| **Totale M1 residuo** | **~6 settimane** |
|
||||
|
||||
## Stima tempi M2 (dopo feedback)
|
||||
|
||||
| Task | Stima |
|
||||
|---|---|
|
||||
| Aggiustamenti post-feedback | variabile (1-2 sett.) |
|
||||
| Fase 5 reale GAIA | 1-2 settimane |
|
||||
| Fase 6 deploy B | 1 settimana |
|
||||
| Fase 7 hardening | 1-2 settimane |
|
||||
| **Totale M2** | **~4-7 settimane** |
|
||||
|
||||
**Totale fino a produzione:** ~10-13 settimane full-time da oggi (2026-04-25), assumendo decisioni aperte risolte in tempo utile.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Stato Progetto TieMeasureFlow — V2.0.0
|
||||
|
||||
> Snapshot al 2026-04-27. Aggiornare ad ogni milestone.
|
||||
|
||||
## Versione corrente
|
||||
|
||||
**V2.0.0** (in sviluppo, branch `V2.0.0` come default su `git.tielogic.xyz`).
|
||||
|
||||
Versione precedente di produzione: `V1.0.7`.
|
||||
|
||||
## Sintesi esecutiva
|
||||
|
||||
Il sistema base (V1.0.7) è completo e collaudato: ricette, task, misurazioni, SPC, report PDF, gestione utenti, dashboard metrologist. La V2.0.0 in corso aggiunge il primo blocco della migrazione **rev04** (stazioni per-tablet) e ristruttura l'intero monorepo secondo lo standard `python-project-spec-design.md` (uv + `src/backend/` + `src/frontend/flask_app/`).
|
||||
|
||||
## Cosa funziona oggi (V2.0.0 — branch corrente)
|
||||
|
||||
### Funzionalità ereditate da V1.0.7
|
||||
- Autenticazione username/password + API key per-utente, ruoli combinabili (Maker, MeasurementTec, Metrologist) + flag `is_admin`.
|
||||
- Recipe versioning copy-on-write: una nuova versione si crea solo se la corrente ha già measurements; altrimenti update in-place.
|
||||
- Editor ricette (Maker) con annotation editor Fabric.js (~1200 LOC, collaudato su tablet).
|
||||
- Workflow operatore tablet: select_recipe → task_list → task_execute → task_complete, con barcode scanner e numpad touch (input USB calibro con burst detection).
|
||||
- Calcolo pass/fail con limiti UTL/UWL/LWL/LTL.
|
||||
- Dashboard SPC: capability (Cp/Cpk/Pp/Ppk), control chart (UCL/LCL = mean ± 3σ), istogramma con curva normale, calcoli puro stdlib (no numpy).
|
||||
- Report PDF (WeasyPrint + Kaleido SVG).
|
||||
- Setup page protetta da `SETUP_PASSWORD` per inizializzazione DB e seed.
|
||||
- i18n IT/EN (Flask-Babel + Alpine.js JSON).
|
||||
- Tema light/dark via `Alpine.store('theme')` + localStorage.
|
||||
|
||||
### Aggiunte V2.0.0 (rev04 Fase 1 — Stazioni per-tablet)
|
||||
- Tabelle `stations` + `station_recipe_assignments` (Alembic migration `002_add_stations.py`).
|
||||
- Modelli ORM: `Station`, `StationRecipeAssignment` con vincolo unique `(station_id, recipe_id)`.
|
||||
- Schemas Pydantic: `StationCreate/Update/Response`, `StationRecipeAssignmentCreate/Response`, `RecipeSummary`.
|
||||
- Service `station_service` (CRUD + assegnazioni + cascade delete).
|
||||
- Router `/api/stations` con CRUD admin + endpoint operatore `GET /api/stations/by-code/{code}/recipes`.
|
||||
- Seed automatico `ST-DEFAULT` con tutte le ricette esistenti (idempotente).
|
||||
- Variabile env client `STATION_CODE` letta da `Config`, helper `APIClient.get_station_recipes()`.
|
||||
- Filtro `select_recipe`: il client mostra solo le ricette assegnate alla propria stazione, errore se `STATION_CODE` non configurato.
|
||||
- **GUI admin completa** in `/admin/stations`: tabella con search, modal create/edit, modal gestione assegnazioni ricette, conferma eliminazione, link in navbar (desktop + mobile).
|
||||
- 47 nuovi test (32 server + 15 client) tutti pass.
|
||||
|
||||
### Aggiunte V2.0.0 (performance + multi-utente)
|
||||
- Gunicorn 5 workers × 4 thread (gthread) — capacità ~20 richieste concorrenti Flask, regge 20+ tablet.
|
||||
- Uvicorn 4 workers + `--proxy-headers --forwarded-allow-ips='*'`.
|
||||
- Rate limit middleware: identificazione IP reale via `X-Forwarded-For` → `X-Real-IP` → `request.client.host`.
|
||||
- Rate limit general 100 → 300 req/min/IP (per-tablet ora, non più condiviso).
|
||||
- Flask `ProxyFix(x_for=1, x_proto=1, x_host=1)` per IP reale dietro Nginx.
|
||||
- `APIClient` propaga `X-Forwarded-For` + `X-Real-IP` (sia JSON che multipart).
|
||||
- 12 test aggiuntivi (7 server + 5 client).
|
||||
|
||||
### Aggiunte V2.0.0 (struttura monorepo)
|
||||
- `pyproject.toml` unico con extra `server`/`client`/`dev`. Niente più `requirements.txt`.
|
||||
- `uv.lock` (77 pacchetti) + `.python-version` (3.11) committati per build riproducibili.
|
||||
- Layout `src/backend/` + `src/frontend/flask_app/` (vedi sotto).
|
||||
- `Dockerfile` (root) + `Dockerfile.frontend` riscritti con `uv sync --frozen --no-dev --extra server|client`.
|
||||
- `docker-compose.{dev,}.yml` con build context `.`.
|
||||
- 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 `"..."` 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)
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── pyproject.toml + uv.lock + .python-version
|
||||
├── Dockerfile (backend) + Dockerfile.frontend
|
||||
├── docker-compose.dev.yml + docker-compose.yml
|
||||
├── nginx/
|
||||
├── uploads/ # volume Docker
|
||||
├── docs/ # raggruppata e indicizzata
|
||||
│ ├── README.md (indice)
|
||||
│ ├── API.md / DEPLOYMENT.md / USER_GUIDE.md / I18N_SETUP.md
|
||||
│ ├── architecture/ # questo file + ROADMAP.md
|
||||
│ ├── archive/ # piani storici
|
||||
│ ├── specs/ # spec esterne (.docx)
|
||||
│ └── superpowers/plans/ # piani TDD dettagliati
|
||||
└── src/
|
||||
├── backend/
|
||||
│ ├── main.py / config.py / database.py
|
||||
│ ├── api/{routers,middleware}/
|
||||
│ ├── models/{orm,api}/
|
||||
│ ├── services/
|
||||
│ ├── migrations/
|
||||
│ ├── templates/
|
||||
│ └── tests/
|
||||
└── frontend/
|
||||
└── flask_app/
|
||||
├── app.py / config.py / compile_translations.py
|
||||
├── blueprints/ (auth, maker, measure, statistics, admin)
|
||||
├── services/ (api_client.py)
|
||||
├── templates/ + static/ + translations/
|
||||
└── tests/
|
||||
```
|
||||
|
||||
## Smoke test status
|
||||
|
||||
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/`) | 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
|
||||
|
||||
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 + WeasyPrint + Plotly/Kaleido.
|
||||
- **Frontend:** Flask + Jinja2 + Alpine.js + TailwindCSS + Fabric.js 5.3.1 + html5-qrcode + Plotly.js + Flask-Babel.
|
||||
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt SSL.
|
||||
- **Tooling:** uv (package mgmt), pytest + pytest-asyncio + httpx + aiosqlite (test).
|
||||
|
||||
## Decisioni architetturali rilevanti
|
||||
|
||||
| Decisione | Stato | Note |
|
||||
|---|---|---|
|
||||
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, USB calipers/barcode, Fabric.js editor, i18n Babel collaudato. Vedi conversazione 2026-04-25. |
|
||||
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, no microservizi. Nessuno stub `nats_client/` creato. |
|
||||
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo 4-5gg refactor + rotture client. Eventuale v2 API in M2. |
|
||||
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare costa 50+ punti di codice + breaking per deploy. Rivedere in M2. |
|
||||
| Variabili `.env` (DB_HOST, SERVER_PORT, ...) | **Mantenute attuali** | Rename a SERVICE_NAME/SERVICE_DOMAIN/API_KEY rinviato (impatta deploy esistenti). |
|
||||
|
||||
## Branch git
|
||||
|
||||
- **Default:** `V2.0.0` (lavoro corrente)
|
||||
- **Mantenuti:** `V1.0.0` … `V1.0.7` (release branches storiche)
|
||||
- **Mergiato e chiuso:** `feature/rev04-phase1-stations` (in `V2.0.0` con commit `ea8e468`)
|
||||
@@ -1,26 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Installa dipendenze sistema per WeasyPrint
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libcairo2 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Crea directory uploads
|
||||
RUN mkdir -p uploads/images uploads/pdfs uploads/logos uploads/reports
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Entry point: Alembic upgrade + Uvicorn
|
||||
CMD ["sh", "-c", "alembic -c migrations/alembic.ini upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 --proxy-headers --forwarded-allow-ips='*'"]
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
# Installa Node.js per Tailwind CSS build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||
|
||||
COPY . .
|
||||
|
||||
# Install and build Tailwind CSS
|
||||
RUN npm install tailwindcss@3 && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
|
||||
# Compile Flask-Babel translations
|
||||
RUN pybabel compile -d translations
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", \
|
||||
"--workers", "5", \
|
||||
"--threads", "4", \
|
||||
"--worker-class", "gthread", \
|
||||
"--timeout", "60", \
|
||||
"--bind", "0.0.0.0:5000", \
|
||||
"--access-logfile", "-", \
|
||||
"--forwarded-allow-ips", "*", \
|
||||
"app:create_app()"]
|
||||
@@ -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>
|
||||
? "{{ _('Sei sicuro di voler disattivare l\'utente') }}"
|
||||
: "{{ _('Sei sicuro di voler riattivare l\'utente') }}""></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: " for ").
|
||||
value = (
|
||||
value.replace(""", '"')
|
||||
.replace(""", '"')
|
||||
.replace("'", "'")
|
||||
.replace("&", "&")
|
||||
)
|
||||
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")
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Verify i18n setup for TieMeasureFlow."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def check_file(path: Path, description: str) -> bool:
|
||||
"""Check if a file exists and report."""
|
||||
exists = path.exists()
|
||||
status = "[OK]" if exists else "[FAIL]"
|
||||
print(f"{status} {description}: {path}")
|
||||
return exists
|
||||
|
||||
def check_json_valid(path: Path) -> bool:
|
||||
"""Check if JSON file is valid."""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
json.load(f)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Verify i18n setup."""
|
||||
print("=== TieMeasureFlow i18n Verification ===\n")
|
||||
|
||||
root = Path(__file__).parent
|
||||
all_good = True
|
||||
|
||||
# Check Flask-Babel files
|
||||
print("Flask-Babel (Server-side):")
|
||||
files = [
|
||||
(root / "translations/babel.cfg", "Babel config"),
|
||||
(root / "translations/it/LC_MESSAGES/messages.po", "Italian .po"),
|
||||
(root / "translations/it/LC_MESSAGES/messages.mo", "Italian .mo"),
|
||||
(root / "translations/en/LC_MESSAGES/messages.po", "English .po"),
|
||||
(root / "translations/en/LC_MESSAGES/messages.mo", "English .mo"),
|
||||
]
|
||||
|
||||
for path, desc in files:
|
||||
if not check_file(path, desc):
|
||||
all_good = False
|
||||
|
||||
# Count messages in .po files
|
||||
if (root / "translations/it/LC_MESSAGES/messages.po").exists():
|
||||
with open(root / "translations/it/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
msgid_count = content.count('msgid "') - 1 # Exclude header
|
||||
print(f" Italian: {msgid_count} messages")
|
||||
|
||||
if (root / "translations/en/LC_MESSAGES/messages.po").exists():
|
||||
with open(root / "translations/en/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
msgid_count = content.count('msgid "') - 1
|
||||
print(f" English: {msgid_count} messages")
|
||||
|
||||
# Check Alpine.js i18n files
|
||||
print("\nAlpine.js i18n (Client-side):")
|
||||
json_files = [
|
||||
(root / "static/js/locales/it.json", "Italian locale"),
|
||||
(root / "static/js/locales/en.json", "English locale"),
|
||||
]
|
||||
|
||||
for path, desc in json_files:
|
||||
if check_file(path, desc):
|
||||
if not check_json_valid(path):
|
||||
all_good = False
|
||||
else:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
keys = count_keys(data)
|
||||
print(f" {keys} total keys")
|
||||
|
||||
# Check app.py integration
|
||||
print("\nApp Integration:")
|
||||
app_py = root / "app.py"
|
||||
if check_file(app_py, "app.py"):
|
||||
with open(app_py, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
checks = [
|
||||
("from flask_babel import Babel", "Flask-Babel imported"),
|
||||
("def get_locale()", "get_locale() function"),
|
||||
("Babel(app, locale_selector=get_locale)", "Babel initialized"),
|
||||
("def set_language(lang)", "set_language endpoint"),
|
||||
]
|
||||
for check_str, check_desc in checks:
|
||||
found = check_str in content
|
||||
status = "[OK]" if found else "[FAIL]"
|
||||
print(f" {status} {check_desc}")
|
||||
if not found:
|
||||
all_good = False
|
||||
|
||||
# Summary
|
||||
print("\n" + "="*40)
|
||||
if all_good:
|
||||
print("[OK] i18n setup verified successfully!")
|
||||
print("\nNext steps:")
|
||||
print("1. Use _() in Python code and templates")
|
||||
print("2. Use $t() in Alpine.js components")
|
||||
print("3. Test language switching: /set-language/en or /set-language/it")
|
||||
else:
|
||||
print("[FAIL] Some checks failed - review errors above")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
def count_keys(obj, depth=0):
|
||||
"""Recursively count keys in nested dict."""
|
||||
if not isinstance(obj, dict):
|
||||
return 0
|
||||
count = len(obj)
|
||||
for value in obj.values():
|
||||
if isinstance(value, dict):
|
||||
count += count_keys(value, depth + 1)
|
||||
return count
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
Reference in New Issue
Block a user