# 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 **backend FastAPI** (porta 8000) e **frontend Flask** (porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.

## Layout del repository (V2.0.0)

A partire dalla migrazione V2.0.0 (struttura conforme alla spec `python-project-spec-design.md`):

```
TieMeasureFlow/
├── pyproject.toml              # Dipendenze monorepo (uv)
├── uv.lock                     # Lock file riproducibile
├── .python-version             # 3.11
├── Dockerfile                  # Backend (uv + uvicorn)
├── Dockerfile.frontend         # Frontend (uv + gunicorn + Tailwind + Babel)
├── docker-compose.dev.yml      # Dev (Nginx)
├── docker-compose.yml          # Prod (Traefik + SSL)
├── nginx/
├── uploads/                    # Volume montato in /app/uploads
├── docs/
└── src/
    ├── backend/
    │   ├── main.py             # Entry FastAPI
    │   ├── config.py
    │   ├── database.py
    │   ├── api/
    │   │   ├── routers/        # 11 router REST
    │   │   └── middleware/     # api_key, rate_limit, security_headers, logging
    │   ├── models/
    │   │   ├── orm/            # SQLAlchemy
    │   │   └── api/            # Pydantic schemas
    │   ├── services/           # Logica business
    │   ├── migrations/         # Alembic
    │   ├── templates/          # Setup page
    │   └── tests/              # pytest
    └── frontend/
        └── flask_app/          # Flask + Jinja2 + Alpine.js (deroga vs spec React,
            ├── app.py          # giustificata: tablet UX server-side, USB calipers,
            ├── config.py       # workflow operatore con Fabric.js)
            ├── blueprints/
            ├── services/
            ├── templates/
            ├── static/
            ├── translations/
            └── tests/
```

Dipendenze gestite con **uv** (no `requirements.txt`):
- `[project] dependencies` = core condivisi (pydantic, dotenv)
- `[project.optional-dependencies] server` = backend FastAPI
- `[project.optional-dependencies] client` = frontend Flask
- `[project.optional-dependencies] dev` = pytest, httpx, aiosqlite

## Comandi di Sviluppo

### Setup iniziale
```bash
cp .env.example .env
uv sync --extra server --extra client --extra dev   # installa tutto
```

### 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
# Backend (terminale 1)
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000

# Frontend (terminale 2) — gunicorn richiede cwd interna per app:create_app()
cd src/frontend/flask_app && uv run --project ../../.. gunicorn --bind 0.0.0.0:5000 app:create_app()

# TailwindCSS watch (terminale 3)
cd src/frontend/flask_app && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
```

### Database & Migrations
```bash
# alembic.ini in src/backend/migrations/, serve il flag -c
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
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head   # Via Docker
```
Nota: `env.py` aggiunge la project root a `sys.path`, sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.

### Test
```bash
# Tutti i test (backend + frontend)
uv run pytest

# Solo backend (SQLite in-memory via aiosqlite, no MySQL richiesto)
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/
```

### 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
```

### i18n (Traduzioni)
```bash
# Estrai stringhe
cd src/frontend/flask_app && uv run pybabel extract -F babel.cfg -k _ -o translations/messages.pot .

# Aggiorna catalogo
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations

# Compila .po → .mo
cd src/frontend/flask_app && uv run pybabel compile -d translations
# oppure
cd src/frontend/flask_app && uv run 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) — `src/backend/`
- **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 `src/backend/migrations/`
- **tests/**: pytest + pytest-asyncio, SQLite in-memory (`sqlite+aiosqlite://`, StaticPool), WeasyPrint mockato via `sys.modules`, rate limit reset tra test

### Client (Flask) — `src/frontend/flask_app/`
- **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 (`src/frontend/flask_app/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 (`src/frontend/flask_app/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 `src/backend/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 `src/backend/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 `src/backend/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 (`src/backend/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 (`src/frontend/flask_app/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 `src/backend/`), `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 50–900)
- Colore secondario: #64748B (grigio acciaio) → `steel` in tailwind.config.js (palette completa 50–900)
- 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`
