Files
TieMeasureFlow/CLAUDE.md
Adriano 767bd7f947 docs: fix middleware order and add missing details to CLAUDE.md
Correct the middleware stack order (was documented backwards due to
Starlette's add_middleware wrapping behavior), document fabric-debug.js
local copy, and note full 50-900 shade palettes for Tailwind colors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 14:24:02 +01:00

247 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Panoramica
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale.
Monorepo con **server FastAPI** (backend API, porta 8000) e **client Flask** (frontend tablet, porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
## Comandi di Sviluppo
### Avvio servizi (Docker)
```bash
docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80)
docker compose up -d # Produzione (Traefik, SSL)
docker compose logs -f server # Log server
docker compose logs -f client # Log client
docker compose ps # Stato servizi
```
### Avvio manuale (senza Docker)
```bash
# Server (terminale 1)
cd server && uvicorn main:app --reload --host 0.0.0.0 --port 8000
# Client (terminale 2)
cd client && flask run --host 0.0.0.0 --port 5000
# TailwindCSS watch (terminale 3)
cd client && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
```
### Database & Migrations
```bash
# alembic.ini è in server/migrations/, serve il flag -c
cd server && alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
cd server && alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
cd server && alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
docker compose exec server alembic -c migrations/alembic.ini upgrade head # Via Docker
```
Nota: `env.py` sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
### Test
```bash
# Server (usa SQLite in-memory via aiosqlite, no MySQL richiesto)
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
# Client
cd client && pytest
cd client && pytest tests/test_auth.py
```
### i18n (Traduzioni)
```bash
# Estrai stringhe
cd client && pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
# Aggiorna catalogo
cd client && pybabel update -i translations/messages.pot -d translations
# Compila .po → .mo
cd client && pybabel compile -d translations
# oppure
cd client && python compile_translations.py
```
### Setup iniziale
Dopo primo avvio, aprire `http://localhost/api/setup` (o `:8000/api/setup` senza Nginx) con la SETUP_PASSWORD dal `.env` per: inizializzare DB, creare admin, caricare dati demo.
## Architettura
### Flusso richieste
```
Browser/Tablet → Nginx (:80/443) → Flask Client (:5000) → APIClient → FastAPI Server (:8000) → MySQL
```
Il client Flask è un frontend server-side che comunica col backend via REST API. Ogni richiesta dal client al server include l'header `X-API-Key` per autenticazione.
### Server (FastAPI) — `server/`
- **main.py**: entry point, lifespan async (`@asynccontextmanager`), registra middleware e 10 router. Health: `GET /api/health`
- **config.py**: `Settings` (pydantic_settings.BaseSettings), legge da `../.env`. Rate limits: login 5/min, general 100/min
- **database.py**: SQLAlchemy 2.0 async engine con `AsyncSession`, pool 10+20 overflow, `pool_recycle=3600`, `expire_on_commit=False`
- **routers/**: auth, users, recipes, tasks, measurements, files, settings, statistics, reports, setup. Il task router (`tasks.py`) usa URL pattern misti: `/api/recipes/{id}/tasks` (list/create), `/api/tasks/{id}` (get/update/delete), `/api/tasks/reorder`, `/api/tasks/{id}/subtasks`, `/api/subtasks/{id}`
- **services/**: recipe_service (versioning copy-on-write), measurement_service (calcolo pass/fail), spc_service (Cp/Cpk/control chart, puro stdlib senza numpy), report_service (WeasyPrint PDF + Kaleido SVG), auth_service (bcrypt + API key)
- **middleware/**: Stack order (outermost→innermost): AccessLogMiddleware → CORSMiddleware → SecurityHeadersMiddleware → RateLimitMiddleware. Nota: `add_middleware()` in Starlette wrappa l'app, quindi l'ultimo aggiunto (AccessLog) è il più esterno. Il commento in `main.py` dice "outermost" per RateLimit ma è fuorviante. api_key.py (auth dependency `get_current_user()`), rate_limit.py (sliding window 60s per-IP, in-memory dicts), security_headers.py (CSP con `unsafe-eval` per Plotly.js, HSTS solo con SSL), logging.py (audit trail async su DB, esclude /api/health, /docs, /openapi.json, /redoc)
- **models/**: User (include `email`, `language_pref`, `theme_pref`), Recipe (`image_path` per preview), RecipeVersion, RecipeTask, RecipeSubtask (`image_path` per immagine specifica), Measurement (`synced_to_csv`, `input_method`), AccessLog, SystemSetting, RecipeVersionAudit
- **schemas/**: Pydantic v2 per validazione I/O API
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `server/migrations/`
- **tests/**: pytest + pytest-asyncio, SQLite in-memory (`sqlite+aiosqlite://`, StaticPool), WeasyPrint mockato via `sys.modules`, rate limit reset tra test
### Client (Flask) — `client/`
- **app.py**: factory pattern `create_app()`, CSRF (`WTF_CSRF_TIME_LIMIT=3600`), Babel i18n (`default_locale="it"`)
- **blueprints/**: auth (login/logout/session), maker (editor ricette con Fabric.js), measure (esecuzione misurazioni), statistics (dashboard SPC con Plotly.js), admin (gestione utenti CRUD, cambio password, toggle attivo — solo `is_admin`)
- **services/api_client.py**: singleton `APIClient` — wrapper HTTP (get/post/put/delete) con gestione errori normalizzata, timeout 30s, header X-API-Key da session
- **templates/**: Jinja2 con `{{ _('string') }}` per i18n. Context processor inietta `current_user`, `current_theme`, `current_language`, `company_logo`, `languages` in tutti i template. Filtro custom `tojson_attr` per JSON sicuro in attributi HTML (escapa `"``&#34;`)
- **static/js/**: Alpine.js 3.x (CDN), Fabric.js 5.3.1 (CDN) + `fabric-debug.js` (copia locale 5.3.0 per debug), locales JSON (it.json, en.json), alpine-init.js (theme store). Componenti specializzati: `numpad.js` (input numerico touch con rilevamento burst USB HID), `caliper.js` (monitor calibro USB passivo), `barcode.js` (scanner QR/barcode con camera via html5-qrcode), `csv-export.js` (export CSV con locale italiano: `;` separatore, `,` decimale), `spc-charts.js` (grafici SPC con Plotly.js), `annotation-editor.js` / `annotation-viewer.js` (editor/viewer annotazioni Fabric.js)
- **translations/**: Flask-Babel, cataloghi .po/.mo per IT/EN. Locale selector: `session["language"]` → Accept-Language → `"it"`
- **config.py**: `PERMANENT_SESSION_LIFETIME=28800` (8h), cookie secure in produzione, `BABEL_DEFAULT_TIMEZONE="Europe/Rome"`
## Template Structure (`client/templates/base.html`)
Ordine blocchi in `base.html`:
```
<head> → {% block extra_head %} (CSS/meta aggiuntivi)
<body> → {% block content %} (contenuto pagina)
→ {% block extra_js %} (script componenti — PRIMA di Alpine)
→ <script defer alpine.js> (Alpine carica PER ULTIMO)
```
`alpine-init.js` (senza defer) carica in `<head>` per registrare `Alpine.store('theme')` prima del paint. I componenti Alpine definiti in `{% block extra_js %}` sono disponibili quando Alpine si inizializza.
Temi: CSS variables in `themes.css`, toggle dark mode via `<html :class="{ 'dark': $store.theme.dark }">`.
## Alpine.js + Jinja2: Regola di Escaping Critica
**MAI** usare `|tojson` direttamente in attributi `x-data="..."` — le `"` del JSON terminano l'attributo HTML.
Pattern corretto:
```html
<script>window.__myData = {{ data|tojson }};</script>
<div x-data="myComponent(window.__myData)">
```
`|tojson` dentro `<script>` è sempre sicuro (nessun contesto attributo HTML).
In alternativa, usare il filtro custom `|tojson_attr` (registrato in `app.py`) che escapa `"``&#34;` per l'uso diretto in attributi HTML.
Per selettori CSS in `x-data`: usare `meta[name=csrf-token]` senza virgolette interne.
## Fabric.js Annotation Editor (`client/static/js/annotation-editor.js`)
Editor annotazioni su disegni tecnici (Fabric.js 5.3.1, ~1200 righe). Pattern critici:
- **Canvas = immagine**: `_fitCanvasToImage()` dimensiona il canvas esattamente come l'immagine scalata (zero spazio vuoto). MAI usare aspect ratio fisso
- **Oggetti MOVE-ONLY**: `hasControls: false`, `lockRotation/lockScaling: true`
- **enableRetinaScaling: false**: elimina mismatch coordinate DPR
- **getPointer override**: quello built-in di Fabric è rotto su retina. Override BCR-based con `canvas.width/rect.width`
- **Caricamento annotations concatenato all'immagine**: pattern `_pendingAnnotations` — salva JSON, carica nel callback dell'immagine. Mai caricare prima che l'immagine sia impostata
- **Arrow endpoint tracking**: aggiorna `arrowX1/Y1/X2/Y2` in `object:modified` con delta da `e.transform.original`
- **Coordinate scaling on load**: `coordScale = currentImageScale / savedImageScale`
JSON annotations v2: `{ version, width, height, imageScale, objects[] }`. Il campo `imageScale` (= `backgroundImage.scaleX`) è fondamentale per mappare coordinate tra editor e viewer a risoluzioni diverse.
Storage: `RecipeTask.annotations_json` (colonna JSON), `RecipeTask.file_path`, `RecipeTask.file_type` (`"image"` | `"pdf"`).
## Pattern Critici
### Recipe Versioning (Conditional Copy-on-Write)
Le ricette usano un versioning condizionale. L'endpoint `PUT /api/recipes/{id}` decide la strategia:
- **Se la versione corrente ha measurements** → copy-on-write: crea nuova `RecipeVersion` con deep-copy di tasks e subtasks. Le misurazioni restano legate alla versione originale.
- **Se la versione corrente NON ha measurements** → update in-place via `update_current_version()` (nessuna nuova versione creata).
La stessa logica si applica nel task router: aggiungere un task a una ricetta con measurements crea una nuova versione.
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `server/services/recipe_service.py`.
### Calcolo Pass/Fail
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `server/services/measurement_service.py`:
- Fuori UTL/LTL → **fail**
- Fuori UWL/LWL ma dentro UTL/LTL → **warning**
- Dentro UWL/LWL → **pass**
### SPC (Statistical Process Control)
Calcoli in `server/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
### Autenticazione
1. Login con username/password → server ritorna `api_key` (64 char random)
2. Client salva api_key in Flask session
3. Ogni richiesta API include header `X-API-Key`
4. Middleware `get_current_user()` autentica, `require_role()` autorizza
5. Dependency pre-built: `require_maker`, `require_measurement_tec`, `require_metrologist`, `require_admin_user`
### Ruoli Utente
Combinabili (JSON array): **Maker** (crea ricette), **MeasurementTec** (esegue misurazioni), **Metrologist** (dashboard SPC). Flag `is_admin` separato.
### Measurement Workflow (Client)
Flusso completo per l'operatore MeasurementTec:
1. **select_recipe** (`/select`) — scelta ricetta con ricerca testo e barcode scanner. Supporta auto-fill da query params (`?recipe=&lot=&serial=`)
2. **task_list** (`/tasks/<recipe_id>`) — lista task della versione corrente. Lotto e seriale persistono in Flask session
3. **task_execute** (`/execute/<task_id>`) — esecuzione misurazioni con numpad touch, input USB calibro (burst detection), annotation viewer. Auto-advance al task successivo. Salvataggio AJAX via `POST /save-measurement` (proxy Flask → FastAPI)
4. **task_complete** (`/complete/<recipe_id>`) — riepilogo con tutte le misurazioni, deviazioni calcolate client-side se mancanti
Il blueprint measure include un **file proxy** (`/measure/api/files/<path>`) perché il browser non può inviare l'header `X-API-Key` direttamente al server FastAPI. Route AJAX aggiuntive: `POST /measure/lookup-barcode` (ricerca ricetta da barcode), `POST /measure/save-traceability` (salva lotto/seriale in session).
### File Storage
Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, WebP, PDF. Limite 50MB. Thumbnail auto-generate per immagini (Pillow). Sanitizzazione filename.
## Test Infrastructure
### Server (`server/tests/conftest.py`)
- SQLite in-memory `sqlite+aiosqlite://` con `StaticPool` (singola connessione condivisa tra fixture, app e test)
- WeasyPrint mockato prima di qualsiasi import server: `sys.modules["weasyprint"] = MagicMock()`
- Rate limit buckets resettati tra test (walk middleware stack → clear dicts)
- Fixture utenti pre-popolati: `admin_user`, `maker_user`, `measurement_tec_user`, `metrologist_user` con API key
- Helper: `create_test_recipe(session, user_id)` → recipe con v1 current, 1 task, 1 subtask
- Helper: `auth_headers(user)``{"X-API-Key": user.api_key}`
- Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency
### Client (`client/tests/conftest.py`)
- `api_client` patchato in 4 blueprint (auth, maker, measure, statistics). **admin NON è patchato** — i test admin devono gestire il mock manualmente
- `logged_in_client` fixture pre-popola session con `api_key`, `user_id`, `language`, `theme` + user dict
- CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False`
## Docker
Due file compose:
- **`docker-compose.dev.yml`**: sviluppo locale con Nginx reverse proxy. 4 servizi: mysql, server, client, nginx. Porte `${NGINX_PORT:-80}` e `${NGINX_SSL_PORT:-443}`.
- **`docker-compose.yml`**: produzione con Traefik (SSL auto via Let's Encrypt). 3 servizi: mysql, server, client. Richiede rete esterna `root_default` (Traefik).
Container: `tmflow-mysql`, `tmflow-server`, `tmflow-client` (+`tmflow-nginx` solo dev). Network: `tmflow-net`. Volumes: `mysql_data`, `upload_data`.
Il server esegue Alembic migrations automaticamente all'avvio (`CMD` include `alembic upgrade head && uvicorn`). Il client compila Babel translations e TailwindCSS nel Dockerfile build. Entrambi usano Python 3.11-slim. Server: uvicorn 2 workers. Client: gunicorn 2 workers (installato inline nel Dockerfile, non in requirements.txt).
Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120s`, cache statica 7d, security headers duplicati a livello proxy.
## Convenzioni Codice
- Python 3.11+, type hints ovunque
- async/await per tutte le operazioni DB (server)
- Pydantic v2 per validazione I/O API
- Nomi variabili e commenti in inglese, UI strings in IT/EN via i18n
- Import ordinati: stdlib → third-party → local, nessun wildcard
- DB queries: `selectinload()` per eager loading, `scalar_one_or_none()` per single-row
## Dipendenze Chiave
- **Server**: fastapi, uvicorn, sqlalchemy[asyncio], asyncmy, alembic, pydantic, bcrypt, pillow, plotly, kaleido, weasyprint, jinja2
- **Client**: flask, flask-babel, flask-wtf, requests
- **Test server**: pytest, pytest-asyncio, httpx, aiosqlite (SQLite in-memory per test)
- **Test client**: pytest, coverage
## Configurazione
Variabili d'ambiente in `.env` (copiare da `.env.example`):
- DB: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_ROOT_PASSWORD` (Docker)
- Server: `SERVER_HOST`, `SERVER_PORT`, `SERVER_SECRET_KEY`, `SERVER_CORS_ORIGINS`
- Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL`
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `server/`), `MAX_UPLOAD_SIZE_MB`
- Docker: `NGINX_PORT`, `NGINX_SSL_PORT`
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE`
## Brand & Tailwind Custom Colors
- Colore primario: #2563EB (blu industriale) → `primary` in tailwind.config.js (palette completa 50900)
- Colore secondario: #64748B (grigio acciaio) → `steel` in tailwind.config.js (palette completa 50900)
- Colori misurazioni: `measure-pass` (#059669), `measure-warning` (#D97706), `measure-fail` (#DC2626)
- Font UI: Inter
- Font numeri: JetBrains Mono
- Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme`