85 Commits

Author SHA1 Message Date
Adriano f20b92bafb test(vision): la suite non lascia piu' immagini nel repository
Il file di test dei limiti di upload scriveva un jpeg vero in
uploads/vision/reference/ a ogni esecuzione: gli mancava la fixture di
dirottamento che test_vision_reference.py ha gia'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 20:28:35 +02:00
Adriano 6bfe3a5d1a docs(spec): registra le correzioni imposte dall'esecuzione
Il gate del fuori tolleranza vale per l'acquisizione e non per la quota:
la regola era pensata per la misura sequenziale e su un evento simultaneo
produceva un blocco permanente.

Piu' il bi-ambiente Python, la versione del motore timbrata al build, la
motivazione della divisione degli extra corretta, e due cose promesse e non
mantenute -- expected_json e image_path -- dichiarate come consegne del
piano 1b invece che lasciate come intenzioni.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 20:00:10 +02:00
Adriano 5717b3e7aa docs(readme): aggiorna il conteggio dei test, con l'ambiente in cui vale
Il README dichiarava ancora 360 pass, 0 fail - numero superato dal
lavoro su questo branch. Aggiornato a 390 pass, 1 fail, 4 skip, con
l'ambiente in cui vale (Python 3.11, uv run pytest, senza l'extra
vision) esplicitato accanto: un conteggio senza il suo ambiente non
dice nulla, regola che il progetto applica già alla sezione del worker
di visione poco sopra. Il fallimento riportato è preesistente e
indipendente da questo lavoro (test_offline.py, copia locale di
Fabric.js non tracciata da git).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:49:21 +02:00
Adriano 85d7976f4e docs(vision): commenta perché camera resta fuori dal pattern client
MeasurementCreate.input_method accetta solo usb_caliper|manual anche se
l'enum ORM e il database permettono ormai camera. È voluto: una misura
da camera deve nascere in vision_service.execute_task, mai da un client
che posta JSON direttamente - non c'è dietro né immagine, né grafo, né
engine_version. Commento aggiunto perché non venga "corretto" tornando
alla lettera dell'enum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:49:21 +02:00
Adriano a72e3f11af test(vision): chiude il punto cieco del confine, "Acquire" era un fantasma
FORBIDDEN confrontava solo il primo segmento del path importato: "from
src.vision.runner import ..." dava radice "src", mai vietata (il
backend importa se stesso di continuo), quindi il test non l'avrebbe
mai vista. Confermato manualmente: la logica vecchia su quell'import
restituisce un insieme vuoto di violazioni. Ora si confrontano i path
puntati per intero contro "src.vision"/"src.vision_worker" come
prefissi, non solo la prima radice.

"Acquire" in FORBIDDEN era un nome di classe/SDK (Balluff "mvIMPACT
Acquire"), mai una radice di import: non poteva mai far scattare nulla.
vs-camera importa sotto lo stesso namespace visionsuite di vs-core
(vendor/visionsuite/packages/vs-camera/pyproject.toml: "il codice si
importa come visionsuite.camera...."), già coperto da "visionsuite":
non c'era una radice separata da aggiungere, quindi è stato tolto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:45:00 +02:00
Adriano 46566ebfa5 fix(vision): build fallisce senza VISION_ENGINE_VERSION, healthcheck vero
Un build senza la variabile produceva un'immagine che partiva e
rispondeva "healthy" pur fallendo ogni richiesta, /health incluso: non
c'era un healthcheck a dirlo. Tre correzioni: RUN test -n
"$VISION_ENGINE_VERSION" in Dockerfile.vision dopo l'ARG, fallisce
subito con il comando da lanciare; healthcheck su /health nel servizio
vision di entrambi i compose; VISION_ENGINE_VERSION documentata in
.env.example accanto a VISION_WORKER_URL, che già c'era.

Verificato con docker build reale: senza la variabile fallisce al passo
del test con il messaggio atteso; con la variabile impostata l'immagine
si costruisce, il container parte, e python3 -c
"urllib.request.urlopen('http://localhost:8100/health')" - lo stesso
comando usato nell'healthcheck - risponde 200 con l'engine_version
giusta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:43:20 +02:00
Adriano 0bd95367bb fix(vision): limite di tipo e dimensione sui tre endpoint di upload
/execute, /preview e /reference-images leggevano l'immagine senza
limite di tipo o dimensione, e /reference-images scriveva su disco con
estensione .png fissa qualunque fosse il tipo reale. Riusa la
convenzione già esistente in files.py (ALLOWED_IMAGE_TYPES,
validate_file_size) invece di inventarne una seconda: tipo controllato
dall'header prima di leggere, dimensione controllata sul corpo letto,
un PDF non è qualcosa che un grafo di visione può misurare.
save_reference_image ora scrive con l'estensione del tipo reale
dell'upload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:41:14 +02:00
Adriano 38ef0587f5 fix(vision): il guasto del worker resta suo, il gate anticipa il worker
I2: _call_worker lasciava propagare httpx.ConnectError e ReadTimeout
(worker fermo o job oltre i 120s), senza handler in main.py: l'operatore
vedeva un 500 nudo. Ora ConnectError->502, ReadTimeout->504, ciascuno con
un messaggio che nomina il guasto. La forma della risposta è validata
alla frontiera: outputs/failures/engine_version mancanti o un body non-
dict non fanno più KeyError, rispondono 502 "malformed".

M3: il gate del fuori tolleranza (pending_authorisation) girava dopo
_call_worker: un operatore bloccato bruciava un'intera esecuzione di
visione prima di ricevere il 409. Spostato prima della chiamata al
worker; le quote necessarie erano già caricate prima, nessun'altra query
serviva.

Copertura: test_vision_worker_transport.py (nuovo) per i quattro casi di
guasto/malformazione; test_vision_execute.py aggiunge
test_a_pending_fail_blocks_before_the_worker_is_called, rossa prima dello
spostamento del gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:38:56 +02:00
Adriano 533edc5be8 fix(vision): il grafo di visione sopravvive a copy-on-write e creazione
_copy_tasks_to_version copiava una lista esplicita di campi che ometteva
vision_json e vision_output: la prima modifica a una ricetta con misure
produceva una versione nuova con i task camera senza grafo. Stessa
famiglia di bug nei percorsi di creazione (POST /api/recipes/{id}/tasks,
POST /api/tasks/{id}/subtasks): i campi erano dichiarati negli schemi ma
mai passati ai costruttori ORM, quindi l'API rispondeva 200 senza
salvare nulla. TDD: test_vision_copy_on_write.py fallisce prima del fix,
passa dopo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:36:18 +02:00
Adriano 3870cc3b0f feat(vision): immagini di riferimento, per comporre senza camera
VisionReferenceImage (migration 013, chained after 012_vision_results):
task_id, path, station_id, device_code, calibration_snapshot,
engine_version, note, expected_json, acquired_at. station_id/device_code/
calibration_snapshot/engine_version/expected_json stay nullable - nessun
endpoint di questo task li popola ancora, arrivano coi Piani 1b/2/4.

save_reference_image salva sotto settings.upload_path (non upload_dir,
come production_export_service). preview esegue il grafo e non salva
niente - comporre non e' misurare, lo asserisce il test.

POST /api/vision/reference-images e /api/vision/preview su require_maker
(non require_measurement_tec di execute): comporre e provare sono lavoro
del Maker, non misure.

Suite intera su Python 3.11.15 locale (uv run pytest -q, SQLite
in-memory per il backend): 1 failed, 373 passed, 4 skipped in 82.35s.
Il fallimento e' quello preesistente e non correlato di
test_no_first_party_script_calls_out (fabric-debug.js locale,
git-ignored) - 371 passed prima di questo task, +2 qui.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:11:47 +02:00
Adriano 398af016f8 test(vision): il confine fra server e VisionSuite è sorvegliato 2026-08-16 19:03:16 +02:00
Adriano b86eeada86 fix(vision): il gate del fuori tolleranza vale per l'acquisizione, non per la quota
Un'acquisizione camera produce N quote nello stesso istante: non c'e "andare
avanti" fra l'una e l'altra, quindi il gate sequenziale di save_measurement
non deve scattare dentro il lotto. execute_task valuta pending_authorisation
una sola volta per l'intera acquisizione (bloccando solo se il fail pendente
non appartiene alle quote di questo task) e passa enforce_tolerance_gate=False
a ogni save_measurement del lotto. Query delle quote ordinata per
marker_number, cosi l'esito non dipende dall'ordine di ritorno di SQLite/MySQL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 18:58:46 +02:00
Adriano ddf7788d77 feat(vision): il server esegue tramite worker e salva misure vere
Aggiunge POST /api/vision/execute: route l'immagine al worker di visione,
mappa le uscite del grafo sulle quote e le salva con save_measurement -
stesso verdetto, stesso gate del fuori tolleranza di ogni altra misura.
Nuova tabella vision_results (una riga per acquisizione, non per quota) e
input_method 'camera' su measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 18:40:12 +02:00
Adriano 05f2937611 fix(vision): dichiara le dipendenze del worker, pin numerico HTTP vs runner
Ruling R9: separa l'extra vision (runner: 4 pacchetti VisionSuite + numpy)
da vision-worker (fastapi, uvicorn, pillow, python-multipart, sopra vision).
Prima il worker risolveva solo perche' vs-pm2d le lista per conto suo; un
domani lo stub di stazione dovra' incorporare il runner senza trascinarsi
dietro un server web che non gli serve.

Aggiorna Dockerfile.vision e README.md al nuovo extra; il test del worker ora
pinna anche il valore numerico del diametro (non solo la chiave), agganciato
alla stessa tolleranza del test in-process del runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 18:28:06 +02:00
Adriano 71da162e1f feat(vision): il worker che espone il runner, versione stampata al build
Container FastAPI separato (Dockerfile.vision, python:3.13-slim) che
espone run_graph/engine_version del Task 2 via POST /run e GET /health,
cosi' l'immagine del server principale non importa mai VisionSuite.

engine_version() ora legge VISION_ENGINE_VERSION se impostata, altrimenti
ricade su git rev-parse nel checkout di sviluppo, e non inventa mai un
valore: senza nessuna delle due solleva un errore esplicito. Nel container
il fallback a git non puo' funzionare (.git del submodule punta fuori dal
build context), quindi Dockerfile.vision prende il commit come build arg
e lo fissa in ambiente; i compose file lo passano da VISION_ENGINE_VERSION.

Nessuna porta pubblicata e nessuna label Traefik sul servizio vision: e'
raggiungibile solo dal server, su tmflow-net.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 18:12:04 +02:00
Adriano 9387e7c306 fix(vision): traduce in inglese docstring e commenti di test_runner.py
R7: i commenti trascritti verbatim dal brief erano in italiano, contro il
vincolo globale "comments in English". Solo prosa toccata - nomi dei test,
assert, valori, struttura invariati. runner.py verificato: gia' in inglese,
nessuna modifica.
2026-08-16 17:52:56 +02:00
Adriano 0a3d1f5222 fix(vision): aggancia i test alla suite di default, skip pulito su 3.11
testpaths include src/vision/tests: prima uv run pytest (365 test) non ci
passava mai. Niente conftest.py: un pytest.importorskip a livello di modulo
in conftest.py rompe l'intera sessione, perche' Skipped eredita da
BaseException e _importconftest cattura solo Exception (verificato con
traceback, provato sia su src/vision/tests sia su src/vision come radice).
La guardia sta invece in cima a test_runner.py, prima di numpy (assente
anch'esso su 3.11) - idioma standard di pytest, cattura pulita a livello di
collection. README aggiornato con il comando reale per farli girare.
2026-08-16 17:39:57 +02:00
Adriano c5f3366dd1 feat(vision): il runner, unica implementazione dell'esecuzione
Aggiunge il sottomodulo vendor/visionsuite (pin f095fcc), src/vision/runner.py
(run_graph/engine_version, pura libreria: niente HTTP, niente DB) e i test.

Deviazione dal brief: vs-task/vs-measure/vs-pm2d dichiarano
requires-python>=3.13 a monte, mentre il monorepo resta a 3.11. L'extra
`vision` porta marker `python_version>='3.13'` cosi' la sync di base
(server+client+dev, 3.11) resta intatta; per lavorare sulla vision serve
`uv sync --extra vision --extra dev --python 3.13`. Ricade sul Task 3: il
worker (Dockerfile.vision) non puo' partire da python:3.11-slim come previsto
dal suo brief, deve usare 3.13.
2026-08-16 17:28:09 +02:00
Adriano 9546ded1e8 fix(vision): allinea la label del task_type al valore corretto dxf_compare 2026-08-16 17:13:26 +02:00
Adriano b578ac9e1b feat(vision): il grafo vive sul task, la quota nomina la sua uscita 2026-08-16 17:10:20 +02:00
Adriano cb0fab8c92 chore: ignora lo scratch di subagent-driven-development
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 17:01:43 +02:00
Adriano 767bede43b docs(plan): fondamenta della visione lato server
Sei task TDD: grafo sul task, runner condiviso, worker separato,
esecuzione con salvataggio misure, prova del confine server/VisionSuite,
immagini di riferimento.

Primo dei quattro piani. Si collauda interamente via API, senza camera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 16:42:25 +02:00
Adriano 245975791f docs(spec): design dell'integrazione VisionSuite
Otto decisioni dal brainstorming, con il motivo di ognuna: motore su
entrambi i lati con versione in lockstep, agente sottile, server che non
importa mai VisionSuite, device a livello PC, esecuzione decisa dalla
stazione, verdetto tenuto fuori dal grafo, sorgente immagine astratta.

Modello dati, API, errori e le quattro domande che restano aperte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 16:29:05 +02:00
Adriano Dal Pastro e0f32fe8a9 docs: la scaletta di collaudo, in ordine e con i numeri già scelti
Tredici punti su quindici sono in esercizio e nessuno li ha percorsi. Finora il
collaudo era «provare le ricette COLLAUDO-A e B», che è un invito a scoprire da
soli in che ordine e con quali valori — cioè a perdere mezza giornata prima di
misurare qualcosa di utile.

Ordinata perché ogni prova prepari la successiva, e perché le due che vanno fatte
adesso vengano prima: il layout, che si vede solo da un monitor largo, e le
richieste di rete, ora che la Content-Security-Policy è appena entrata in vigore.

Due dettagli verificati sul codice prima di scriverla, perché mandavano a caccia
di pulsanti inesistenti: il timer non parte finché non si preme «Avvio
Produzione», che compare solo dopo il primo ciclo confermato — il primo pezzo è
attrezzaggio, non produzione; e una quota già presa si rimisura cliccandola in
alto, che è la via d'uscita dal blocco fuori tolleranza senza chiamare nessuno.

Detto anche cosa la scaletta non copre, incluso il fatto che su una VPS pubblica
staccare la rete non prova niente: si guarda dove vanno le richieste, e il giro
vero si fa in reparto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 22:08:40 +00:00
Adriano Dal Pastro 2101b9b2c9 docs: porta stato e roadmap ai fatti del 28/07
Erano fermi allo snapshot V2.0.0 di fine aprile: parlavano di Fasi rev04 da
iniziare, di quattro test rotti e di uno stack che scaricava le librerie da CDN.
Niente di tutto questo è più vero, e un documento di stato sbagliato è peggio di
un documento di stato assente — qualcuno ci si fida.

STATO_PROGETTO: i quindici punti con il loro stato e dove vivono nel codice, cosa
è entrato in V3.0.0 raggruppato per tema, le dieci migrazioni, la suite a 360
pass. In fondo la sezione che conta di più, «cosa non è stato provato»: tredici
punti sono in esercizio e nessuno li ha percorsi su un tablet, il punto 14 non è
mai stato riprodotto su un dispositivo, il punto 12 non è mai stato provato a rete
staccata. Serve a non confondere «i test passano» con «funziona in reparto».

ROADMAP: riscritta intorno a quello che resta e in che ordine — il collaudo prima
di tutto, perché è l'unico lavoro che non aspetta risposte da nessuno, con la
tabella di cosa guardare e come si vede che è giusto. Poi il punto 4 (due o tre
giorni, a decisioni chiuse), l'innesto GAIA e l'installazione di settembre.

Le decisioni aperte sono ora le D-1…D-9 del documento del 28/07, non più le
D-0.x di aprile: la corrispondenza è scritta, così chi torna sui vecchi documenti
non si perde. Stessa cosa per le sette Fasi rev04, con dove è finita ciascuna:
due assorbite, una sostituita, una ridimensionata dal fatto che una rete isolata
rende l'aggiornamento automatico privo di senso.

Lo snapshot V2.0.0 è conservato in docs/archive/ invece di essere sovrascritto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 22:03:40 +00:00
Adriano Dal Pastro c4a429d952 feat(offline): la policy arriva dove le pagine si aprono davvero
Punto 12. Le cinque librerie erano già state portate in casa (28ee44b): nessun
template carica più niente dalla rete, il worker di PDF.js è locale in tutti e
quattro i file che lo impostano, i font sono woff2 nel pacchetto. Verificato riga
per riga, e le impronte SHA-256 in VERSIONS.md corrispondono ancora.

Mancava però la seconda metà dell'intervento, e mancava dove conta. La
Content-Security-Policy a sola origine locale esisteva sul backend, cioè sulle
risposte API; le pagine HTML le serve il client Flask, che non mandava alcuna
policy. La regola stava scritta dove non poteva essere infranta e assente dove
poteva. Ora il client la manda su ogni risposta.

Serve meno a difendere e più a non far tornare indietro il punto: un tag verso un
CDN aggiunto fra sei mesi viene rifiutato dal browser alla scrivania, dove c'è la
rete e l'errore si legge in console, invece che in reparto dove la rete non c'è.

Tailwind era ancora agganciato a `tailwindcss@3` nel Dockerfile: stesso difetto
che il documento cita per le librerie del browser, un gradino più in basso. Fissato
a 3.4.19, che è la versione con cui l'immagine in esercizio è stata costruita.

I test non renderizzano niente: leggono i sorgenti, perché il difetto che devono
impedire si scrive in un template e non si vede finché non si stacca la rete.
Controllano anche le impronte — una libreria sostituita sul posto tiene lo stesso
nome e la stessa riga in tabella, e l'hash è l'unica parte che se ne accorge.

Annotato in VERSIONS.md che `html5-qrcode` è l'unica libreria dichiarata e mai
caricata: lo scanner da fotocamera non è incluso da nessuna pagina, il lettore che
l'operatore usa è un campo di testo. Va portata in casa prima di accenderlo.

README aggiornato a V3.0.0: novità della versione punto per punto, ruolo
Supervisor, librerie locali al posto dei CDN, stato dei test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:58:13 +00:00
Adriano Dal Pastro 78816dfe8c fix(ui): una cornice sola, e smette di spostarsi sotto le dita
Punto 14. La segnalazione dell'operatore del 28/07 non era stata circoscritta sul
codice: «le dimensioni delle viste cambiano a seconda del menu». Sono quattro
meccanismi distinti, tutti leggibili senza avere il tablet in mano.

Ogni vista si dichiarava la propria larghezza: sette valori diversi su diciassette
template, e nessuno coincideva con quello della navbar, che sta a max-w-7xl su
tutte. Il contenuto era quindi disallineato dalla barra sopra di sé, e il
disallineamento cambiava a ogni pagina. Il percorso dell'operatore faceva
1280 → 1024 → tutto schermo → 1280 in quattro passaggi.

Il padding verticale mescolava py-8 e py-6 fra viste consecutive, così la prima
card cambiava altezza a ogni schermata.

La barra di scorrimento è classica da 8px e occupa spazio: una pagina lunga la
mostrava, una corta no, e la schermata di misura la toglie sempre. A ogni
navigazione tutto il contenuto centrato — navbar compresa — si spostava di 8px.

La schermata di misura era alta 100vh, che su Android e iOS è l'altezza con la
barra dell'indirizzo nascosta: su un tablet il piede, dove stanno «Fine ciclo
misura» e il tastierino, finiva sotto il bordo.

Ora: .tmf-page e .tmf-page-narrow in themes.css, con i valori esatti della navbar;
scrollbar-gutter: stable; 100dvh dove serve. Due larghezze in tutto il prodotto al
posto di sette, e il criterio è il tipo di pagina — liste, tabelle e tele stanno
larghe, i moduli stanno stretti.

Login e schermata di misura tengono la loro geometria, per i motivi scritti in
docs/architecture/LAYOUT.md e ripetuti in EXEMPT dentro il test: la prima non ha
navbar a cui allinearsi, la seconda non deve scorrere mentre si misura.

Il test è statico e guarda i sorgenti: la vista scritta domani copierà la cornice
dalla vicina, ed è lì che la deriva ricomincia.

Resta da chiedere all'operatore su quale schermo l'ha vista: in verticale su un
tablet quasi tutte quelle larghezze collassano e il difetto non si nota.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:41:16 +00:00
Adriano Dal Pastro 98b1c5ae85 feat(measure): l'operatore entra nella sequenza, non in un elenco
Punto 10. Sceglere una ricetta apriva la lista completa dei task e chiedeva
all'operatore di decidere da dove cominciare prima ancora di aver fatto
qualcosa. Ora AVVIA IN SEQUENZA porta dentro il primo task; la lista resta,
un livello sotto, per quando serve vederla tutta o tornare indietro.

- nuova rotta /measure/start/<ricetta>: memorizza lotto e seriale (prima lo
  faceva la lista, che ora si salta), verifica la tracciabilità obbligatoria
  e apre il primo task
- GET /api/measurements/task-progress: quante quote ha già preso ogni task di
  misura, contate per quota e non per tentativo, delimitate dalla produzione
  aperta o, fuori produzione, dall'operatore
- la lista distingue «Incompiuto 1/3» da «Completato 3/3»: un task lasciato a
  metà non somigliava più a uno mai aperto
- «Visualizza singolo TASK» non è più riservato al Maker — dire «incompiuto»
  senza dare la strada per tornarci sarebbe una lamentela, non una funzione
- nel task «Riepilogo» diventa «Completato» e si apre solo quando il ciclo è
  chiuso: altrimenti sarebbe la scorciatoia che rende facoltative le quote
- «Fine ciclo misura» si vede da subito, spento, e dice quante quote mancano;
  prima compariva a task già finito, quando non serviva più saperlo

Il pulsante verde del footer diventa «Task successivo»: due bottoni con la
stessa parola addosso sono uno di troppo.

Test: 334 (erano 309). Il side effect dei mock del client Flask ora risponde
per endpoint invece che per turno — una lista posizionale si rompe appena una
pagina fa una domanda in più al server, che è come crescono tutte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:31:42 +00:00
Adriano Dal Pastro 919ee7ac79 chore(collaudo): ricette e capoturno per la sessione di accettazione
Uno script che prepara sul sistema quello che i test dimostrano ma nessuno ha
ancora visto su uno schermo: due ricette con impostazioni opposte, perche' i
punti da collaudare le vogliono cosi'.

COLLAUDO-A e' quella da guidare — due task di misura con task documentali prima,
in mezzo e dopo, lotto obbligatorio, digitazione ammessa, intervallo di due
minuti. Serve per il ciclo di misura, il conto alla rovescia che va oltre lo
zero, il ritorno automatico alla misura e il gate del fuori tolleranza.

COLLAUDO-B e' quella da guardare — solo calibro, lotto e seriale obbligatori: il
tastierino non viene disegnato e Avvia non parte finche' mancano i dati.

Le quote hanno limiti scelti perche' ogni esito sia a un tasto di distanza:
10.00 conforme, 10.30 attenzione, 12.00 fuori tolleranza.

Crea anche un capoturno con il ruolo Supervisor: un amministratore passerebbe il
controllo, ma il collaudo deve esercitare il ruolo che usera' l'officina.

Rilanciarlo trova le ricette e si ferma; --replace le rifa' da zero, misure
comprese, che e' quello che serve fra una sessione e l'altra e mai per sbaglio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:10:00 +00:00
Adriano Dal Pastro e2aae88858 feat(measure): il fuori tolleranza si autorizza, non si chiude
Il punto 5 c'era a meta': la schermata apriva il modale del capoturno e bloccava
l'avanzamento automatico, ma il modale si chiudeva con Annulla o con un click
sullo sfondo, la misura era gia' salvata, e `pendingAdvance` veniva impostato e
non letto da nessuno. Era una conferma, non uno sbarramento.

Ora l'autorizzazione finisce sulla misura: due colonne, chi ha autorizzato e
quando. Finche' una quota e' fuori tolleranza e nessuno l'ha autorizzata, il
server rifiuta la misura della quota successiva e la chiusura del ciclo. Non e'
la schermata a impedirlo: la schermata risparmia solo il viaggio.

Rimisurare la stessa quota resta possibile — il calibro scivola, il pezzo si
riposiziona — e una seconda lettura in tolleranza libera il blocco, perche' conta
l'ultima lettura di ogni quota. Quante volte si possa riprovare e' il punto 4, e
tutte le letture restano comunque a registro. Un warning non blocca: fuori dai
limiti di attenzione ma dentro la tolleranza e' dentro la tolleranza.

Ricaricare la pagina era il modo piu' semplice per scavalcare il vecchio gate.
Non lo e' piu': la schermata chiede al server, all'apertura, se una quota sta
aspettando, e si ritrova davanti lo stesso blocco.

Sparisce /validate-supervisor, che verificava le credenziali e buttava via la
risposta. Al suo posto un endpoint che le credenziali le usa per scrivere
l'approvazione dove serve. Il controllo del capoturno si sposta in auth_service,
accanto al resto delle credenziali: fermo linea, chiusura e fuori tolleranza
fanno la stessa domanda, e solo una delle tre riguarda la produzione.

Il file di statistica guadagna authorised_by e authorised_at: mostrava il
fallimento e non la decisione, che e' la meta' che un auditor chiede.

Migrazione 010: due colonne nullable sulle misure. Le righe esistenti restano
nulle — retrodatare un'autorizzazione mai avvenuta sarebbe inventarsi un record
di audit, e quelle produzioni sono chiuse da un pezzo.

Test: +18 (309). Coprono il rifiuto della quota successiva, la rimisura ammessa,
il rilascio del blocco con una lettura buona, il ciclo che non si chiude, il
capoturno registrato sulla misura, le credenziali sbagliate e chi capoturno non
e', il file di statistica, e il fatto che una produzione chiusa non blocchi la
successiva. Aggiunto uploads/statistics/ al gitignore: i test che chiudono una
produzione scrivevano nel repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:54:24 +00:00
Adriano Dal Pastro 5aa3d595ad feat(recipes): la ricetta decide cosa pretendere e come si misura
Tre cose che prima non erano di nessuno diventano regole della ricetta, decise da
chi la scrive e fatte valere dal server.

Punto 8 — tracciabilita' obbligatoria. Lotto e seriale si dichiarano obbligatori
sulla ricetta. L'operatore li inserisce alla selezione, dove il pulsante non si
attiva finche' mancano, e la stessa regola vale sulla lista task, raggiungibile
anche per link diretto, e sul barcode: uno sbarramento che dura solo finche'
qualcuno prende in mano il lettore non e' uno sbarramento. La produzione non si
apre e la misura non si salva senza cio' che la ricetta pretende, perche' un
valore senza il suo lotto non e' riconducibile a niente, e accorgersene dopo
significa accorgersene tardi.

Punto 9 — inserimento manuale. Una ricetta puo' vietare i valori digitati, ed e'
il valore predefinito: il calibro e' lo strumento, digitare e' cio' che va
concesso. Dove e' vietato il tastierino non viene disegnato (non nascosto con i
CSS: il markup nascosto e' markup che si puo' rimostrare) e restano correzione e
conferma, perche' una lettura sbagliata va cancellata. Il controllo vero e' sul
server: una regola che vive solo nel frontend e' un consiglio.

Migliorato al passaggio il riconoscimento del calibro. Contava solo la raffica di
cifre, cosi' una lettura corta come "9.5" — tre battute — finiva registrata come
digitata a mano; ora conta anche l'Invio che il wedge manda dentro la stessa
raffica. Senza questa correzione il divieto avrebbe respinto misure legittime.

Punto 11 — formattazione delle descrizioni. A capo e grassetto sopravvivono: chi
scrive le ricette incolla dal PDF della scheda tecnica e il testo arrivava
appiattito, da risistemare a mano ogni volta. Nessun HTML viene accettato o
salvato — il testo viene escapato e gli unici tag nel risultato sono quelli
prodotti dal renderer. La sanificazione e' questa: non c'e' niente da sanificare
perche' non si accetta niente. Le stesse due regole in Jinja e in JS, cosi' una
descrizione si legge uguale ovunque. E la descrizione ora si vede anche in
esecuzione: era scritta per l'operatore e la vedeva solo chi la scriveva.

Migrazione 009: tre colonne sulla ricetta. Le due di tracciabilita' partono
false, che e' il comportamento di oggi; l'inserimento manuale parte *vero* sulle
ricette gia' esistenti — il default della colonna e' falso, quindi le ricette
nuove sono solo-calibro, ma spegnerlo d'ufficio su quelle in uso fermerebbe una
linea alla misura successiva. Chi possiede la ricetta lo decide dall'editor.

Test: +26 (291). Coprono il rifiuto sul server per lotto, seriale e valore
digitato, il calibro sempre ammesso, le regole che sopravvivono alla nuova
versione, il tastierino assente in pagina, l'Avvia sbarrato, e il renderer delle
descrizioni compreso il caso in cui si prova a farci passare un tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:27:19 +00:00
Adriano Dal Pastro bde8fafd77 feat(production): la misura torna a cercare l'operatore
Il conto alla rovescia non si ferma piu' a zero. Oltre la scadenza continua
nell'altro senso e dice da quanto la linea e' in ritardo, in rosso, su tutte le
schermate del percorso di misura: lista task, riepilogo, scelta ricetta. Un
ritardo va letto, non dedotto. Allo scadere dell'intervallo la misura si
ripropone da sola: cicalino, un avviso di cinque secondi, e l'operatore torna al
primo task di misura da qualunque schermata si trovi.

Un ciclo copre tutti i task di misura della ricetta, quindi solo l'ultimo lo
chiude e fa ripartire l'intervallo. Farlo ripartire al primo avrebbe fatto
misurare una ricetta con tre task di misura tre volte piu' spesso di come e'
configurata. E' il server a decidere quale task chiude il ciclo, perche' e' lui
a conoscere la sequenza: la lista dei task di misura viaggia con la produzione
(measurement_task_ids), che e' anche cio' che permette a una schermata qualsiasi
di sapere dove riportare l'operatore.

Aggiunta la rimisura: si gira il pezzo e si misura di nuovo dentro lo stesso
ciclo, senza chiudere niente e senza guadagnare tempo sulla scadenza. Le due
letture restano entrambe in statistica, che e' il motivo per cui si prendono.

Chi sta gia' misurando quando l'intervallo scade e' in ritardo, non perso: la
banda diventa rossa e lo si lascia lavorare. Portarlo altrove a meta' ciclo
cancellerebbe quote che ha davanti agli occhi senza guadagnare nulla.

Sistemato anche il pulsante "Conferma ciclo" dell'overlay: alzava una bandierina
locale e basta, quindi il ciclo non veniva mai registrato sul server da quella
strada. Ora passa da confirmCycle come il pulsante della barra.

Migrazione 008: il registro eventi della produzione impara task_measured e
remeasure, e la colonna task_id — un task_measured che non dice quale task non
registra niente di utile. I valori nuovi entrano tutti insieme perche' allargare
una enum MySQL riscrive la tabella, stesso ragionamento dei tipi di task in 007.
Verificata su SQLite (batch mode) e in MySQL con --sql.

La logica dell'orologio — come si legge un ritardo, quando suona, dove sta la
misura — vive in un solo posto (production-clock.js) e la schermata di misura la
usa invece di riscriverla.

Test: +10 (265). Coprono il ciclo che non riparte a meta', la rimisura che non
sposta la scadenza, il rifiuto a linea ferma, la sequenza dei task di misura
esposta dalla produzione, i proxy Flask e la validita' JS della lista task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:31:51 +00:00
Adriano Dal Pastro 6fbff2fe76 feat(tasks): il tipo di un task si dichiara, non si deduce
Punto 2 del documento modifiche del 28/07, prima richiesta di Menoncin.

Il sistema distingueva un task di misura da uno documentale deducendolo: se aveva
quote era una misura, altrimenti una nota. Conseguenza: un task di misura a cui le
quote non erano ancora state inserite veniva trattato come nota, e il sistema si
comportava in modo diverso a seconda di quanto fosse completa la ricetta.

Nuovo campo task_type su recipe_tasks (migrazione 007) con nota, misura e disegno,
piu' xf_compare e camera_measure gia' nell'enum: allargare un enum MySQL piu' avanti
e' un ALTER su tabella viva, e non costa nulla prevederli adesso.

Il backfill riproduce la classificazione che era a schermo, cosi' nessuna ricetta
cambia comportamento all'aggiornamento: i task con quote diventano 'measure'; quelli
senza quote ma con un disegno allegato diventano 'drawing' e non 'note', perche' e'
gia' cio' che mostravano - chiamarli note sarebbe stato l'unico punto in cui questa
migrazione cambiava le carte in tavola.

Il tipo viene copiato esplicitamente nel copy-on-write del versioning: una nuova
versione che lo perdesse riclassificherebbe in silenzio tutti i task alla prima
modifica di una ricetta. Il task "Technical Drawing" creato d'ufficio quando si
carica un disegno su una ricetta senza task nasce come 'drawing'.

Lato operatore decide ora il tipo, non il conteggio delle quote: fermo linea, fine
produzione, avvio produzione e la barra di produzione seguono il tipo. Dove servono
davvero delle quote da mostrare - elenco marker, tastierino, fine ciclo misura -
resta anche il controllo che ce ne sia almeno una, e un task di misura ancora privo
di quote lo dichiara invece di somigliare a una nota. Nella lista task compare il
tipo, cosi' si vede prima di aprire.

La 007 e' stata eseguita su SQLite usa e getta con tre righe costruite apposta - una
con quote, una col solo disegno, una nota secca - e il backfill le classifica come
atteso. Il backfill girera' sui dati reali del cliente, provarlo a mano non bastava.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:15:18 +00:00
Adriano Dal Pastro 7bc3c1f938 feat(production): fermo linea e fine produzione fanno finalmente qualcosa
Punto 6 del documento modifiche del 28/07. I due pulsanti esistevano, chiedevano
correttamente l'autorizzazione del capoturno, e poi ricadevano su un commento:
"handled by GAIA integration (future)". E' la riga di apertura del documento.

Ora agiscono sulla produzione aperta: fermo linea sospende e congela il conto alla
rovescia, la ripresa lo fa ripartire restituendo il tempo del fermo, fine
produzione chiude ed emette il file di statistica. Le credenziali del capoturno
vanno direttamente all'endpoint, che le verifica e registra sulla traccia della
produzione chi ha autorizzato cosa: validarle a parte non lascerebbe traccia, e
sarebbe un giro in piu'.

In interfaccia il pulsante di fermo diventa "Riprendi" quando la linea e' ferma, e
una banda dichiara lo stato con il valore congelato a video: una linea ferma che
sembra in marcia e' il modo in cui si salta un intervallo di misura senza
accorgersene.

Il file di statistica ha richiesto di legare le misure alla produzione (migrazione
006): Measurement conosceva solo versione, lotto e seriale, nessuno dei quali
separa una produzione dalla successiva sulla stessa ricetta e sullo stesso lotto,
quindi "le misure dell'intera produzione" non era una query. Il CSV rispetta i
separatori configurati in system_settings e porta con se' i limiti di tolleranza:
senza quelli un esito pass/fail non e' piu' ricalcolabile dal file a distanza di
anni, che e' il senso di un documento per audit. Le misure esportate vengono
marcate synced_to_csv. Se la produzione non ha misure non viene scritto nulla: un
file vuoto sarebbe rumore nella cartella, non evidenza.

L'invio a GAIA resta assente e non abbozzato, con il punto d'innesto dichiarato in
close_run: una chiamata vuota che sembra collegata e' peggio di niente. Tutto il
resto del punto 6 non dipende dal gestionale e funziona adesso.

Anche la 006 e' stata eseguita su SQLite usa e getta prima di essere considerata
buona, e anche qui la prova ha trovato un difetto: aggiungere una colonna con
foreign key fa emettere ad alembic un ALTER di vincolo, che SQLite rifiuta. Ora usa
batch_alter_table con il vincolo nominato, che su MySQL resta un ALTER normale e su
SQLite ricostruisce la tabella - cosi' la chiave esterna non va persa per far
contento il dialetto dei test.

Corretto anche il proxy di salvataggio misura, che chiamava /api/measurements senza
slash finale e pagava un redirect 307 a ogni singola misura.

Traduzioni: pybabel aveva di nuovo indovinato sette voci marcandole fuzzy, e in
italiano "Linea ferma" era diventato "Lingua Preferita" su una banda di sicurezza.
Tradotte per esteso in IT ed EN e tolti i flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:32:43 +00:00
Adriano Dal Pastro 55e5e0153f feat(production): dai una vita propria alla produzione, lato server
Punto 1 del documento modifiche del 28/07, il prerequisito su cui poggiano i
punti 3, 4 e 6.

Lo stato di una produzione viveva dentro una pagina del browser: timer, conteggio
cicli e flag "produzione avviata" erano variabili Alpine di task_execute.html, e
la navigazione fra task e' un ricaricamento completo, quindi cambiando task si
perdeva tutto. Da qui il loop di misura che non reggeva, il fermo linea che non
aveva nulla da fermare e l'assenza di storico.

Nuove tabelle production_runs e production_events (migrazione 005), endpoint REST
senza stato in memoria di processo - con un'app di stazione installata su ogni PC
il database e' l'unico posto condiviso - e il frontend che legge lo stato
all'apertura invece di tenerlo in memoria.

Tre scelte di modello:

- la scadenza e' un timestamp assoluto (next_measurement_at), non un contatore:
  il countdown si ricalcola da li' a ogni caricamento, e lasciarne andare la
  differenza sotto zero dara' gratis il contatore del ritardo del punto 3. Al
  client vanno i secondi gia' calcolati, non il timestamp: un datetime naive
  verrebbe letto nel fuso del browser e il conto sarebbe sfasato dell'offset UTC;
- l'intervallo di misura e' copiato sulla produzione, non referenziato: modificare
  la ricetta a produzione avviata non deve spostare una scadenza in corso;
- active_station_id rispecchia la stazione finche' la produzione e' aperta e va a
  NULL alla chiusura. Con un vincolo unico sopra, "una stazione = una produzione
  aperta" e' una garanzia del database e non un controllo soggetto a race; i NULL
  non collidono, quindi le produzioni chiuse si accumulano senza disturbo.

Il fermo linea congela il conto alla rovescia e alla ripresa la scadenza viene
traslata della durata del fermo, non ricalcolata: un fermo non regala ne' toglie
tempo all'operatore. L'autorizzazione del capoturno passa da authenticate_user e
non da un login, che rigenererebbe la sua API key buttando giu' la sessione che ha
aperta altrove.

La migrazione e' stata eseguita davvero, non solo scritta, su uno SQLite usa e
getta: upgrade e downgrade girano e le colonne coincidono con i modelli. La prova
ha trovato un difetto - create_unique_constraint dopo create_table e' un ALTER,
che SQLite rifiuta - ora il vincolo e' dichiarato dentro create_table.

Fuori da questo commit, per stare nei confini del punto 1: l'API espone gia'
pause, resume e close, ma i pulsanti fermo linea e fine produzione restano da
collegare (punto 6), e il rientro forzato sulla misura allo scadere e' il punto 3.

Corretti due difetti trovati strada facendo: env.py non importava ne' Station ne'
ProductionRun, quindi l'autogenerate di Alembic era gia' cieco sulle stazioni; e
task_execute.html, lo schermo con piu' JavaScript dell'applicazione, non era
coperto dal test di sintassi. Aggiungerlo ha richiesto di correggere l'helper, che
validava le espressioni Alpine solo come espressione singola e bocciava
@click="a = false; b = true", forma che Alpine accetta: ora prova entrambe le
letture e fallisce solo se cadono tutte e due.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:20:43 +00:00
Adriano Dal Pastro e7889f80a3 docs: aggiungi il documento delle modifiche del 28/07/2026
Elenco dei 15 punti concordati con Trafilo, con lo stato verificato sul codice al
commit 2a56632, l'architettura d'installazione decisa il 28/07 e le nove domande
la cui risposta dipende dal cliente. E' il riferimento del lavoro su V3.0.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:55:59 +00:00
Adriano Dal Pastro a159eb2b8b feat(stations): ricette in lista, reset per riga e cambio stazione al volo
Punto 7 del documento modifiche del 28/07, terza richiesta di Menoncin.

GET /api/stations ora usa StationWithRecipesResponse, che era gia' definita nello
schema senza che nessun endpoint la usasse. Non costa query in piu':
Station.assignments e' gia' selectin-loaded. In lista compaiono le sole ricette
attive, coerente con cio' che l'operatore vede davvero a quella stazione.

DELETE /api/stations/{id}/recipes azzera le assegnazioni di una stazione senza
eliminarla, cosi' si riassegna da capo. E' idempotente: azzerare una stazione gia'
vuota risponde removed=0, non 404. In tabella e' un pulsante per riga, con
conferma perche' l'azione e' distruttiva.

Il cambio stazione al volo (?station=CODE, override in sessione) serve al
collaudo: senza, provare N stazioni richiede N PC. E' dietro il flag
STATION_SWITCH_ENABLED, default 0. Non e' legato al ruolo admin perche'
/measure/select richiede MeasurementTec, e gatearlo su is_admin avrebbe escluso
proprio chi fa il collaudo. In fabbrica l'identita' della stazione viene
dall'installazione locale, e misurare contro le ricette di un'altra stazione
romperebbe in silenzio la tracciabilita': per questo il codice richiesto e'
validato prima di essere memorizzato - un refuso lascia l'operatore sulla
stazione configurata con un messaggio, invece di incastrarlo su una stazione
inesistente - e quando l'override e' attivo la pagina lo dichiara, con il codice
configurato accanto e un link per ripristinare.

Le traduzioni: pybabel update aveva indovinato dieci voci da stringhe simili
marcandole fuzzy, in entrambi i cataloghi. "Azzera Stazione" era diventato "Crea
Stazione", l'opposto, su un dialogo distruttivo. Non si vedeva perche' la
compilazione salta le fuzzy e ricade sul sorgente italiano, ma sarebbe emerso al
primo build con --use-fuzzy. Tradotte per esteso le 17 stringhe nuove in IT ed EN
e tolti i flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:42:17 +00:00
Adriano Dal Pastro 28ee44bcf1 feat(client): includi librerie e font in locale per il funzionamento offline
Punto 12 del documento modifiche del 28/07. L'installazione a Trafilo e'
on-premise su rete di produzione isolata: finche' Alpine.js, Plotly, PDF.js,
Fabric.js e i Google Fonts venivano scaricati da CDN a ogni apertura di pagina,
senza internet l'interfaccia non partiva affatto (Alpine governa tutta la UI).

Le librerie passano in static/vendor/ con la versione nel nome del file, e la
CSP del backend si chiude a solo-origine. Il worker di PDF.js e' ripuntato in
tutti e quattro i template che lo usano: ripuntando solo lo script principale la
libreria si carica in locale ma il worker continua a cercare internet, e sembra
funzionare finche' non si apre un disegno.

Alpine e' pinnato a 3.15.12, cioe' la versione a cui "3.x.x" risolveva oggi: il
congelamento non cambia il comportamento di quanto e' gia' in esercizio, e toglie
il fatto che l'applicazione cambiasse da sola a ogni rilascio degli autori -
rilevante per le evidenze ISO 9001 / IATF 16949.

Inter e JetBrains Mono sono font variabili: un solo woff2 per subset copre tutti
i pesi, dove Google serviva lo stesso file sotto otto URL. Tenuti i soli subset
latin e latin-ext, che coprono per intero italiano e inglese.

Provenienza, versioni e impronte SHA-256 in static/vendor/VERSIONS.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:41:57 +00:00
Adriano 2a566321f3 fix(migrations): riparenta input_duration dopo 003_measurement_interval
Il rebase aveva creato due head Alembic (003_input_duration e
003_measurement_interval entrambi figli di 002): upgrade head all'avvio
del server sarebbe fallito. Rinominata la revision in 004_input_duration
con down_revision 003_measurement_interval — catena di nuovo lineare.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:45:12 +02:00
Adriano d0e07b5e5f docs(readme): aggiunge input_duration_ms e aggiorna stato test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:42:24 +02:00
Adriano 3f03ec8ab1 feat(measure): memorizza tempo di inserimento misura (input_duration_ms)
Colonna nullable su measurements + migration 003, campo opzionale negli
schemi API (create singola e batch), passthrough nel proxy Flask e timer
lato client in task_execute: parte all'attivazione del subtask, si
resetta su auto-advance e navigazione manuale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:42:24 +02:00
Adriano Dal Pastro edb7eb382c Merge branch 'ui/restyling': login restyle, navbar macros, new brand icon
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:09:21 +00:00
Adriano Dal Pastro 8c61a557f8 ui: redesign brand icon and fix clipped wordmark
- new mark: measurement-span glyph on rounded brand-gradient badge
  (favicon, inline logo, standalone tmflow-logo.svg)
- wordmark moved from SVG <text> with hardcoded x offsets (clipped
  "Flow" past the 260px viewBox, font-dependent overlap) to plain HTML
  spans — never clips, natural kerning, theme-adaptive
- _app_logo.html params: logo_class, wordmark_class, logo_id (unique
  gradient id when logo appears twice per page)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:01:08 +00:00
Adriano Dal Pastro 821c7c39d5 ui: restyle login, dedupe navbar with Jinja macros, touch polish
- login: theme variables instead of hardcoded slate colors, decorative
  brand background, show/hide password toggle, dynamic copyright year
- navbar: nav links rendered via shared nav_link/nav_links macros
  (desktop + mobile from one source), active state now also in mobile
  menu, aria-current on active link, fix nonexistent w-4.5 class
- themes.css: btn :active press feedback, btn-lg variant, card hover
  border accent, brand accent-color and ::selection
- fix tmf-input vs pl-10 cascade conflict (themes.css loads after
  tailwind.css) with important utilities — also fixes recipe search icon
- app.py: inject current_year in template globals
- i18n: add Mostra/Nascondi password (IT/EN)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:35:53 +00:00
Adriano Dal Pastro 83f8e2d332 docs: fix pybabel extract path in CLAUDE.md (babel.cfg lives in translations/)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:07:14 +00:00
Adriano Dal Pastro f3e593610b i18n: refresh Babel catalogs, complete EN translations
- pybabel extract + update against current templates/blueprints
- EN: translate 36 missing entries and fix 24 wrong fuzzy matches
  (supervisor flow, cycle/timer, PDF import, traceability, settings)
- IT: normalize 149 missing/fuzzy entries (msgstr = msgid, source is Italian)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:51:12 +00:00
Adriano Dal Pastro fd571c479e fix: code review — security fixes, dedup, cleanup
- setup: validate roles against users.VALID_ROLES (Supervisor was missing)
- files: fix path traversal prefix-match edge case (is_relative_to),
  dedupe path validation into resolve_upload_path(), use logging not print
- measurements: extract shared _build_measurement_filters() helper
- client app: prevent open redirect via Referer on /set-language
- maker: guard resp.json() in parse-technical-sheet proxy
- measure/maker: extract shared file proxy into services/file_proxy.py
- measure: localize supervisor validation error messages
- annotation-editor: remove global keydown listener in destroy()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:16:29 +00:00
Adriano Dal Pastro 25a788f430 ui(measure): drop N/M prefix on task title, fix hidden directive
- Remove the "1/2"/"2/2" prefix before the task title; the number circle
  already shows the index.
- The directive showed only "…": line-clamp-1 + whitespace-pre-wrap clamped a
  leading blank line to just the ellipsis. Use line-clamp-2 without pre-wrap so
  the description is actually visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:52:18 +00:00
Adriano Dal Pastro 61c265b38b ui(measure): move measurement/attachment indicators to card top-right
Move the per-task measurement count and attachment indicator out of the info
column to the top-right of each card (self-start), keeping the title/directive
column clean. Measurement count shown as a compact number+icon with tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:35:45 +00:00
Adriano Dal Pastro 74b348988e ui(measure): merge task/measurement counts at top, slimmer task cards
- Merge the bottom "N task · M misurazioni" indicator into the top header
  badge; remove the now-empty bottom navigation row.
- Make task cards shorter: smaller padding, smaller number circle, single-line
  title (with N/M inline) and one-line directive, tighter spacing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:34:07 +00:00
Adriano Dal Pastro a36a37aca0 ui(measure): move "Seleziona altra ricetta" to the top of the task list
The change-recipe button was only at the bottom of the task list, hard to
find. Move it just under the breadcrumb so operators can switch recipe
easily; the bottom row now keeps just the task/measurement count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:30:09 +00:00
Adriano Dal Pastro b8d04c54f7 fix(measure): hide per-task "Visualizza Task" link for non-Maker operators
On the task list, each task card exposed a "Visualizza Task" link that jumps
straight into an individual task. Pure operators (MeasurementTec without
Maker) should follow the guided flow and start only with AVVIA. Gate the
per-task link behind the Maker role; admins/makers who also measure keep it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:26:15 +00:00
Adriano Dal Pastro 34a72e6f1a fix(measure): skip completion overlay during START, auto-advance task
The previous change made the overlay phase-aware but it still popped up after
every task during the START run. Operators want to flow straight to the next
task. Skip showing the "Misurazioni Complete" overlay when production has not
started and this is not the last task — call goToNextTask() directly. The
overlay still appears on the last START task (for "Avvio Produzione") and in
production (for "Conferma ciclo").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:36:29 +00:00
Adriano Dal Pastro 4c75a32186 fix(measure): START cycle advances to next task instead of blocking
When all subtasks of a task were measured, the completion overlay only
offered "Riepilogo" (which leaves the work cycle) or "Conferma ciclo" (which
keeps the operator stuck on the same task and starts the interval timer).
During the START run — before production is started — neither lets the
operator simply move to the next task.

Make the overlay phase-aware:
- Production started: keep "Conferma ciclo".
- START, not the last task: primary action "Task successivo" -> goToNextTask().
- START, last task: "Avvio Produzione" -> startProductionFromOverlay(), which
  starts production and kicks off the interval cycle.

Add isLastTask getter and startProductionFromOverlay(); EN translation for the
new label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:30:52 +00:00
Adriano Dal Pastro fdfe1072d8 fix(measure): supervisor modal — backspace works and fields reset
Two issues with the "Autorizzazione capoturno" modal:

1. Backspace and digits did not work in the username/password fields. The
   numpad component installs a window-level keydown handler (for the USB
   caliper / keyboard wedge) that preventDefault()s digits, Backspace, Enter
   etc. It swallowed those keys inside the modal inputs. Guard handleKeydown
   to ignore events whose target is an editable field (INPUT/TEXTAREA/SELECT/
   contentEditable); the numpad has no text inputs of its own.

2. The modal kept the previous username/password: opening only set the flag,
   and closing via backdrop click did not clear the fields. Add
   openSupervisorModal()/closeSupervisorModal() that always reset username,
   password and error, and use them for every open (fermo_linea,
   fine_produzione, out_of_tolerance) and close (backdrop, Annulla).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:11:31 +00:00
Adriano Dal Pastro 6ff78b2150 feat(roles): add dedicated Supervisor (capoturno) role
Until now the out-of-tolerance authorization ("Autorizzazione capoturno")
required is_admin, conflating shift-leader authority with full system
administration. Introduce a combinable Supervisor role so a capoturno can
authorize overrides without admin privileges (roadmap D-0.8 / Phase 2).

Backend:
- users router: add "Supervisor" to the allowed roles (centralized as
  VALID_ROLES, deduplicating the create/update validation).
- middleware: add require_supervisor dependency (admins still bypass).

Frontend:
- measure.validate_supervisor: authorize users with the Supervisor role OR
  is_admin (was is_admin only).
- admin/users: Supervisor checkbox + orange role badge.
- profile: explicit Supervisor badge styling.
- EN translation for the new role label.

roles is a free JSON column, so no migration is required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:02:24 +00:00
Adriano Dal Pastro bff577b461 fix(measure): separate traceability inputs from recipe search
On the recipe selection page the Lotto/Seriale fields shared a single
filter bar with the "Cerca ricetta" search box, so operators read them as
search filters instead of data-entry fields and never filled them in.

Split into two cards: a search filter, and a distinct "Tracciabilità"
section with heading, helper text, accent border and monospace inputs with
amber/indigo icons matching the lot/serial badges shown downstream. Add EN
translations for the new strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:48:54 +00:00
Adriano Dal Pastro 5cc5123a0c fix(theme): apply per-user theme_pref on login, persist toggle to server
alpine-init.js resolved the theme purely from localStorage, so it never
reflected the logged-in user's theme_pref: switching user kept the previous
user's theme because localStorage persists per-browser.

- base.html injects window.__SERVER_THEME from the session theme (empty when
  anonymous, preserving OS-preference behaviour on the login page).
- alpine-init.js now treats the server value as authoritative for logged-in
  users (server > localStorage > OS > light) and syncs localStorage to it.
- The navbar toggle now persists the choice via POST /auth/set-theme, which
  updates the session and the user's theme_pref, so it survives navigation
  and matches on the next login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:32:11 +00:00
Adriano Dal Pastro 2eb5c51353 fix(auth): role-aware post-login landing to avoid 403 for non-MeasurementTec
Login always redirected to measure.select_recipe, which is gated by
@role_required("MeasurementTec"). A Maker-only (or Metrologist-only) user
was therefore bounced straight to a 403 right after a successful login.

Add _post_login_landing() which selects the landing endpoint from the user's
roles (MeasurementTec -> measure, Maker -> maker recipes, Metrologist ->
statistics, admin -> users, else profile). Used for both fresh login and the
already-authenticated short-circuit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:24:20 +00:00
Adriano Dal Pastro b325eb3512 fix(deploy): attach prod stack to the real Traefik network
The prod compose joined the external network 'root_default', but the
running Traefik instance is configured with --providers.docker.network=traefik
and 'root_default' is empty. Traefik therefore had no reachable address for
the tmflow-web/tmflow-api routers and served 404 for tieflow.tielogic.xyz.

Point traefik-net at the external network 'traefik'. After redeploying with
docker-compose.yml the site routes correctly (HTTPS 200, valid ACME cert,
/api/ path-prefix router working).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:12:20 +00:00
Adriano Dal Pastro a7254d8932 fix(tasks): eager-load version in reorder endpoint to fix MissingGreenlet
The reorder_tasks endpoint loaded RecipeTask with selectinload(subtasks)
only. Serializing via TaskResponse reads RecipeTask.recipe_id, a @property
that traverses the version relationship, triggering a lazy load outside the
async greenlet context -> MissingGreenlet error.

Add selectinload(RecipeTask.version), matching the pattern already used in
the get-task flow. Fixes the previously order-dependent test_reorder_tasks
failure; full suite now 179 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:50:58 +00:00
Adriano Dal Pastro d2bf0b5828 feat(maker): AI parsing schede tecniche PDF via OpenRouter
- Nuovo servizio ai_service.py: estrae testo da PDF (pdfplumber) + analisi AI (OpenRouter)
- Endpoint POST /api/recipes/{id}/parse-technical-sheet con validazione file
- UI: bottone "Importa da PDF" nel task editor con modale upload + preview editabile
- Task suggeriti modificabili/rimovibili prima della creazione bulk
- Config: OPENROUTER_API_KEY e OPENROUTER_MODEL in .env
- Dipendenze: pdfplumber + httpx aggiunti a server deps

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:01:00 +00:00
Adriano Dal Pastro 9bd605c958 feat(measure): bottone Avvio Produzione placeholder dopo primo ciclo misura
- Bottone "Avvio Produzione" appare solo dopo il primo ciclo confermato
- Conferma visiva "Produzione avviata" dopo il click
- Placeholder per futura integrazione GAIA (TODO nel codice)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:32:48 +00:00
Adriano Dal Pastro e05eb66a1c feat(measure): timer cicalino con countdown e buzzer dopo fine ciclo misura
- Blueprint: passa measurement_interval_minutes dalla ricetta al template task_execute
- Dopo "Fine ciclo misura": countdown MM:SS con banner amber animato sopra footer
- Allo scadere: 3 beep sonori (Web Audio API 880Hz) + reset misurazioni per nuovo ciclo
- Contatore cicli visibile nel banner timer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:22:10 +00:00
Adriano Dal Pastro 6bfe0a98e8 docs: aggiunge PDF richieste cliente maggio 2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:26 +00:00
Adriano Dal Pastro 1da7e5c7af feat(measure): rework workflow operatore — AVVIA, capoturno, fine ciclo, blocco logout
- task_list: pulsante AVVIA sequenziale + rinomina "Inizia Misure" → "Visualizza Task"
- task_execute header: aggiunti "Lista task", "Riepilogo", "Fermo linea", "Fine Produzione"
- task_execute footer: "Fine ciclo misura" (attivo quando tutte le quote misurate) + "Completato"
- Rimosso auto-advance al task successivo, flusso ora esplicito via Completato
- Misura fuori tolleranza: blocco avanzamento + modale login capoturno (validate_supervisor)
- Blocco logout durante misurazioni attive (icona lucchetto su task_execute)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:54:37 +00:00
Adriano Dal Pastro 0748ce9b1e feat: timer intervallo misura su ricetta + auto-logout per inattività
- Recipe: nuovo campo measurement_interval_minutes (migration 003) con UI nel recipe editor
- Auto-logout: impostazione di sistema configurabile da admin/settings, timer inattività JS
  con warning modale 60s prima del logout, tracking eventi utente throttled
- Navbar: aggiunto link "Impostazioni" per admin (desktop + mobile)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:36:02 +00:00
Adriano Dal Pastro b9c767fb0c feat(ui): rinomina Misure→Attività, textarea descrizione task, tool Testo annotazioni
- Navbar e breadcrumb: label "Misure" rinominata "Attività" (IT) / "Activities" (EN)
- Descrizione task: input sostituito con textarea multilinea + display whitespace-pre-wrap
- Annotation editor: nuovo tool "Testo" con fabric.IText (inline editing, serializzazione, viewer)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:26:23 +00:00
Adriano Dal Pastro 6772e3166a fix(setup,ui): risolve errori setup + logo SVG theme-aware
- setup: corregge path templates (sibling di api/, non figlio) e firma TemplateResponse
- setup: invalida sessione client dopo operazioni che ruotano l'API key (init DB, creazione admin, cambio password, attivazione utente)
- api_client: include il nome del campo nei messaggi di errore 422 (FastAPI validation)
- ui: nuovo componente _app_logo.html (SVG inline theme-aware) usato su login e navbar
2026-05-20 07:42:49 +00:00
Adriano 182aa9fa9a feat(ui): recipe thumbnail su Select Recipe + fit (object-contain) su Gestione Ricette
- /measure/select: aggiunta miniatura 64×64 accanto al nome ricetta
  (object-contain per mantenere l'immagine intera, non croppata).
  Lo schema backend RecipeSummary esponeva solo id/code/name/active:
  aggiunti image_path e description così la card può rendere thumb
  e sottotitolo.
- /maker/recipes: la miniatura passa da object-cover (crop) a
  object-contain (fit), per visualizzare l'intera immagine della
  ricetta senza tagli.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:24:36 +02:00
Adriano f1c7a28296 style(admin/stations): use bordered cards instead of divide-y rows
Le liste "Ricette disponibili" e "Assegnate alla stazione" passano da
una lista con separatori interni (divide-y) a card individuali con
bordo arrotondato e spaziatura: migliora la leggibilità e la
percezione di trascinabilità dei singoli elementi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:19:40 +02:00
Adriano 52d78ea5c1 fix(measure): task_execute full-screen layout + hide UI for tasks without measurements
Pagina /measure/execute/<task_id>:
- Il pannello destro (info subtask + tolerance + numpad) e la sidebar
  marker spariscono quando il task non ha subtask: l'utente vede solo
  intestazione + disegno a tutta larghezza, senza colonne vuote né
  tastierina numerica fuori contesto.
- Risolta la scrollbar verticale: la pagina ora occupa esattamente
  l'altezza del viewport. Il layout sfrutta nuovi block override in
  base.html (body_class, wrapper_class, main_class, footer) per
  disattivare scroll e footer solo su questa pagina, lasciando intatte
  le altre.

Cataloghi i18n: aggiunte traduzioni EN/IT mancanti e rimossi i flag
"fuzzy" auto-generati da pybabel update (Flask-Babel ignora i fuzzy,
mostrando il msgid italiano anche in EN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:19:34 +02:00
Adriano fb96bad000 fix(tests): align stale tests with current API + bypass identity-map cache
- test_recipes: split update test into in-place vs copy-on-write paths
  (PUT now updates in-place when current version has no measurements).
  Added _seed_measurement() helper to force copy-on-write where needed.
- test_tasks::reorder: expunge_all() between POST and GET so the shared
  test session re-fetches RecipeVersion.tasks instead of returning the
  cached collection. Prod uses one session per request, so identity-map
  staleness only affects tests.
- test_measure::save_measurement_proxy: payload aligned to current API
  (version_id is required; pass_fail/deviation are computed server-side).

All 179 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:07:32 +02:00
Adriano 6a8931e108 fix(i18n): aggiunge traduzioni EN mancanti per GUI /admin/stations
Le ~42 stringhe di admin/stations.html e navbar (Stazioni, Postazione,
Gestione Stazioni, ...) non erano state estratte nel catalogo .pot dopo
l'aggiunta della GUI, quindi in EN si vedeva ancora italiano via fallback
msgid. pybabel update aveva poi auto-matchato alcune chiavi simili come
"fuzzy", producendo traduzioni assurde (Postazione → Settings, ecc.) che
Babel ignora a runtime.

Rigenerato messages.pot via pybabel extract, applicate traduzioni EN
corrette per tutte le entry fuzzy, rimossi flag fuzzy. IT lasciato con
msgstr vuoto (fallback su msgid = italiano nativo).

Tech debt: serve un test guard che faccia fail se pybabel extract produce
diff vs messages.pot committato.
2026-05-11 18:13:20 +02:00
Adriano 85a00dea1b fix(api): allow MeasurementTec to list measurements for task_complete
GET /api/measurements required Metrologist, but the operator workflow
calls it from task_complete.html to render the post-recipe riepilogo —
silently empty for every non-Metrologist user. Open to any authenticated
user; payload carries no PII beyond numeric values + recipe ref.

Regression test added.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:19:36 +02:00
Adriano 563b7789f4 docs(readme): rewrite for V2.0.0 — uv, src/ layout, stations, docs index
Brings the entry-point README in line with the V2.0.0 restructure:

- Replace all server/ + client/ paths with src/backend/ +
  src/frontend/flask_app/.
- Replace pip install -r requirements.txt with the uv workflow
  (uv sync --extra server --extra client --extra dev).
- Manual setup section uses uv run uvicorn / uv run flask /
  uv run alembic / uv run pybabel, all driven from the project root.
- Document the V2.0.0 additions: STATION_CODE per-tablet, /admin/
  stations GUI, gunicorn 5x4 + uvicorn 4 worker scaling, X-Forwarded
  -For-aware rate limiting (RATE_LIMIT_GENERAL default 300).
- Add tooling section (uv, pyproject.toml, uv.lock, .python-version,
  pytest stack).
- Documentation section now points at the new docs/ index plus the
  STATO_PROGETTO + ROADMAP architecture pair as the canonical "what
  is done / what is next" references.
- Variabili d'Ambiente: add STATION_CODE, RATE_LIMIT_LOGIN,
  RATE_LIMIT_GENERAL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:48:51 +02:00
Adriano e4b29c0b2d chore(docs): consolidate documentation, drop dead files
Cleanup
- Remove src/backend/Dockerfile.legacy and
  src/frontend/flask_app/Dockerfile.legacy (history is in git, build
  uses the new uv-based root Dockerfile / Dockerfile.frontend).
- Remove src/frontend/flask_app/verify_i18n.py (had hardcoded paths
  pointing at the old client/ tree).

Group docs/
- New docs/README.md indexes everything in one place.
- New docs/architecture/STATO_PROGETTO.md: snapshot of what works in
  V2.0.0 (inherited V1.0.7 features, rev04 Phase 1 stations,
  worker scaling, src/ restructure, test status, stack, decisions).
- New docs/architecture/ROADMAP.md: what's next — Phases 2-7 of the
  rev04 migration with status, open client decisions (D-0.1 through
  D-0.10), tech debt and time estimates for M1 / M2.
- Move PIANO_IMPLEMENTAZIONE.md (90KB V1.0.0 plan) to
  docs/archive/2026-02-06-piano-implementazione-v1.md (historical).
- Move Schema sviluppo SW TieFlow_rev04-2026.docx to docs/specs/
  with ISO date filename so the customer spec is now tracked.
- Move src/frontend/flask_app/I18N_SETUP.md to docs/I18N_SETUP.md
  and rewrite paths to the new src/frontend/flask_app/ tree.

.dockerignore: simplified now that legacy Dockerfiles are gone;
docs/ stays excluded from the build context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:43:57 +02:00
Adriano 1a0431366f chore(v2): restructure monorepo to src/ layout with uv
Aligns the repo with the python-project-spec-design.md template chosen
for V2.0.0. Big move, no logic changes. The 3 pre-existing test
failures (test_recipes::test_update_recipe, test_recipes::
test_recipe_versioning, test_tasks::test_reorder_tasks, plus the
client test_save_measurement_proxy) survive unchanged.

Layout changes
- server/        -> src/backend/
- server/middleware/ -> src/backend/api/middleware/
- server/routers/    -> src/backend/api/routers/
- server/models/     -> src/backend/models/orm/
- server/schemas/    -> src/backend/models/api/
- server/uploads/    -> uploads/ (project root, mounted volume)
- server/tests/      -> src/backend/tests/
- client/            -> src/frontend/flask_app/ (Flask kept; React
  deroga is documented in CLAUDE.md, justified by tablet UX, USB
  caliper/barcode workflow and Fabric.js integration)

Tooling
- pyproject.toml: monorepo with [project] core deps and
  optional-dependencies server / client / dev. Replaces both
  server/requirements.txt and client/requirements.txt.
- uv.lock + .python-version (3.11) committed for reproducible builds.
- Dockerfile (root, backend) and Dockerfile.frontend rewritten to use
  uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles
  preserved as Dockerfile.legacy for reference but excluded from build
  context via .dockerignore.
- docker-compose.dev.yml + docker-compose.yml: build context now ".",
  dockerfile pointing to the root files.

Code adjustments forced by the move
- Every "from config|database|models|schemas|services|routers|middleware
  import ..." rewritten to its src.backend.* equivalent (50+ files
  including indented inline imports inside test bodies).
- src/backend/migrations/env.py: insert project root into sys.path so
  alembic can resolve src.backend.* imports regardless of cwd.
- src/backend/config.py: env_file ../../.env (was ../.env), upload_path
  resolves project root via parents[2].
- src/backend/tests/conftest.py + tests: import ... from src.backend.*
  instead of bare names; old per-directory pytest.ini files removed in
  favor of root pyproject.toml [tool.pytest.ini_options].
- .gitignore: uploads/ at root, src/frontend/flask_app/static/css/
  tailwind.css path; .dockerignore tightened.
- CLAUDE.md: rewrote sections "Layout del repository", "Comandi di
  Sviluppo", "Database & Migrations", "Test", "i18n", and all path
  references throughout the architecture sections.

Verified
- uv lock resolves 77 packages; uv sync --extra server --extra client
  --extra dev installs cleanly.
- uv run pytest: 171 passed, 4 pre-existing failures.
- uv run alembic -c src/backend/migrations/alembic.ini check loads
  config and metadata (errors only on the absent local MySQL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:26:47 +02:00
Adriano 86df67f2e5 perf: scale workers + per-tablet rate limiting for 20 concurrent users
The default 2-worker gunicorn could only serve 2 concurrent tablet requests,
queueing the rest, and the rate limiter saw every tablet as the same Nginx
container IP, so 20 users would have collectively burned through the
100 req/min general bucket.

- gunicorn: 5 workers x 4 gthread, --forwarded-allow-ips=*, access log
- uvicorn: 4 workers, --proxy-headers, --forwarded-allow-ips=*
- RateLimitMiddleware: resolve real client IP from
  X-Forwarded-For -> X-Real-IP -> request.client.host
- Bump rate_limit_general 100 -> 300 req/min/IP (per tablet now)
- Flask: ProxyFix(x_for=1, x_proto=1, x_host=1) so request.remote_addr
  is the tablet IP, not the Nginx IP
- APIClient: forward X-Forwarded-For + X-Real-IP to FastAPI for both
  JSON and multipart/files calls; safe no-op outside request context
- 12 new tests (7 server + 5 client) covering header precedence,
  forwarding behavior and ProxyFix install

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:07:43 +02:00
Adriano ea8e4687b5 merge: rev04 Phase 1 — stations and per-tablet identity (feature/rev04-phase1-stations into V2.0.0) 2026-04-25 11:54:51 +02:00
273 changed files with 29692 additions and 8229 deletions
+38 -10
View File
@@ -1,15 +1,43 @@
__pycache__ # Build context exclusions: keep image small and rebuilds fast.
*.pyc
*.pyo # VCS
.pytest_cache
.git .git
.gitignore .gitignore
.env .gitattributes
*.md
node_modules # Local envs and caches
.venv .venv
venv venv
*.egg-info .env
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.coverage .coverage
htmlcov htmlcov/
.mypy_cache .mypy_cache/
*.egg-info
# IDE / editor
.vscode/
.idea/
*.swp
*~
# Node
node_modules/
# Local-only notes
docs/
*.docx
# Uploads are a runtime volume, never baked in.
uploads/
# Claude Code
.claude/
.omc/
# Competitor analysis (local only)
Concorrente/
+22 -1
View File
@@ -24,14 +24,35 @@ API_SERVER_URL=http://localhost:8000
# Each physical tablet/PC deployment must set this unique per-station value. # Each physical tablet/PC deployment must set this unique per-station value.
# Leave empty only for a single-station all-in-one demo using ST-DEFAULT. # Leave empty only for a single-station all-in-one demo using ST-DEFAULT.
STATION_CODE=ST-DEFAULT STATION_CODE=ST-DEFAULT
# Allow switching station from the URL (?station=CODE) during commissioning, so a
# single PC can exercise several stations. Leave at 0 in production: on the shop
# floor the station identity comes from the local install, and measuring against
# another station's recipes would silently break traceability.
STATION_SWITCH_ENABLED=0
# --- Vision ---
# Internal address of the vision worker. Never exposed outside tmflow-net.
VISION_WORKER_URL=http://vision:8100
# The pinned VisionSuite commit, stamped into the vision image at build time
# (Dockerfile.vision ARG). Required: `docker compose build vision` fails
# immediately without it. Set with:
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD)
VISION_ENGINE_VERSION=
# --- File Storage --- # --- 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 MAX_UPLOAD_SIZE_MB=50
# --- Setup Page --- # --- Setup Page ---
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
# --- AI (OpenRouter) ---
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare
# --- Docker --- # --- Docker ---
DB_ROOT_PASSWORD=root_password_change_me DB_ROOT_PASSWORD=root_password_change_me
NGINX_PORT=80 NGINX_PORT=80
+21 -11
View File
@@ -33,21 +33,27 @@ env/
Thumbs.db Thumbs.db
desktop.ini desktop.ini
# Uploads (server-side files) # Uploads (server-side files, now at project root)
server/uploads/images/* uploads/images/*
server/uploads/pdfs/* uploads/pdfs/*
server/uploads/logos/* uploads/logos/*
server/uploads/reports/* uploads/reports/*
!server/uploads/images/.gitkeep uploads/general/
!server/uploads/pdfs/.gitkeep # Statistics files emitted when a production is closed: shop-floor evidence, not
!server/uploads/logos/.gitkeep # source. They live on the mounted volume in production.
!server/uploads/reports/.gitkeep uploads/statistics/
!uploads/images/.gitkeep
!uploads/pdfs/.gitkeep
!uploads/logos/.gitkeep
!uploads/reports/.gitkeep
# TailwindCSS output # TailwindCSS output
client/static/css/tailwind.css src/frontend/flask_app/static/css/tailwind.css
# Node # Node
node_modules/ node_modules/
src/frontend/flask_app/package.json
src/frontend/flask_app/package-lock.json
# Flask-Babel compiled # Flask-Babel compiled
*.mo *.mo
@@ -63,10 +69,14 @@ node_modules/
htmlcov/ htmlcov/
# Debug files # Debug files
client/static/js/fabric-debug.js src/frontend/flask_app/static/js/fabric-debug.js
# Misc # Misc
nul nul
# Competitor analysis (local only) # Competitor analysis (local only)
Concorrente/ Concorrente/
docker-compose.override.yml
# Subagent-driven development scratch: ledger, briefs, review packages
.superpowers/
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/visionsuite"]
path = vendor/visionsuite
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git
+1
View File
@@ -0,0 +1 @@
3.11
+102 -35
View File
@@ -5,10 +5,65 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Panoramica ## Panoramica
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale. 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. 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 ## Comandi di Sviluppo
### Setup iniziale
```bash
cp .env.example .env
uv sync --extra server --extra client --extra dev # installa tutto
```
### Avvio servizi (Docker) ### Avvio servizi (Docker)
```bash ```bash
docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80) docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80)
@@ -20,51 +75,63 @@ docker compose ps # Stato servizi
### Avvio manuale (senza Docker) ### Avvio manuale (senza Docker)
```bash ```bash
# Server (terminale 1) # Backend (terminale 1)
cd server && uvicorn main:app --reload --host 0.0.0.0 --port 8000 uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
# Client (terminale 2) # Frontend (terminale 2) — gunicorn richiede cwd interna per app:create_app()
cd client && flask run --host 0.0.0.0 --port 5000 cd src/frontend/flask_app && uv run --project ../../.. gunicorn --bind 0.0.0.0:5000 app:create_app()
# TailwindCSS watch (terminale 3) # TailwindCSS watch (terminale 3)
cd client && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch cd src/frontend/flask_app && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
``` ```
### Database & Migrations ### Database & Migrations
```bash ```bash
# alembic.ini è in server/migrations/, serve il flag -c # alembic.ini in src/backend/migrations/, serve il flag -c
cd server && alembic -c migrations/alembic.ini upgrade head # Applica migrazioni uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
cd server && alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
cd server && alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback
docker compose exec server alembic -c migrations/alembic.ini upgrade head # Via Docker docker compose exec server uv run alembic -c src/backend/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. 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 ### Test
```bash ```bash
# Server (usa SQLite in-memory via aiosqlite, no MySQL richiesto) # Tutti i test (backend + frontend)
cd server && pytest # Tutti i test uv run pytest
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 # Solo backend (SQLite in-memory via aiosqlite, no MySQL richiesto)
cd client && pytest uv run pytest src/backend/tests/
cd client && pytest tests/test_auth.py 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) ### i18n (Traduzioni)
```bash ```bash
# Estrai stringhe # Estrai stringhe
cd client && pybabel extract -F babel.cfg -k _ -o translations/messages.pot . cd src/frontend/flask_app && uv run pybabel extract -F translations/babel.cfg -k _ -o translations/messages.pot .
# Aggiorna catalogo # Aggiorna catalogo
cd client && pybabel update -i translations/messages.pot -d translations cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
# Compila .po → .mo # Compila .po → .mo
cd client && pybabel compile -d translations cd src/frontend/flask_app && uv run pybabel compile -d translations
# oppure # oppure
cd client && python compile_translations.py cd src/frontend/flask_app && uv run python compile_translations.py
``` ```
### Setup iniziale ### Setup iniziale
@@ -78,7 +145,7 @@ Browser/Tablet → Nginx (:80/443) → Flask Client (:5000) → APIClient → Fa
``` ```
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. 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/` ### Server (FastAPI) — `src/backend/`
- **main.py**: entry point, lifespan async (`@asynccontextmanager`), registra middleware e 10 router. Health: `GET /api/health` - **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 - **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` - **database.py**: SQLAlchemy 2.0 async engine con `AsyncSession`, pool 10+20 overflow, `pool_recycle=3600`, `expire_on_commit=False`
@@ -87,10 +154,10 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
- **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) - **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 - **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 - **schemas/**: Pydantic v2 per validazione I/O API
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `server/migrations/` - **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 - **tests/**: pytest + pytest-asyncio, SQLite in-memory (`sqlite+aiosqlite://`, StaticPool), WeasyPrint mockato via `sys.modules`, rate limit reset tra test
### Client (Flask) — `client/` ### Client (Flask) — `src/frontend/flask_app/`
- **app.py**: factory pattern `create_app()`, CSRF (`WTF_CSRF_TIME_LIMIT=3600`), Babel i18n (`default_locale="it"`) - **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`) - **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 - **services/api_client.py**: singleton `APIClient` — wrapper HTTP (get/post/put/delete) con gestione errori normalizzata, timeout 30s, header X-API-Key da session
@@ -99,7 +166,7 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
- **translations/**: Flask-Babel, cataloghi .po/.mo per IT/EN. Locale selector: `session["language"]` → Accept-Language → `"it"` - **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"` - **config.py**: `PERMANENT_SESSION_LIFETIME=28800` (8h), cookie secure in produzione, `BABEL_DEFAULT_TIMEZONE="Europe/Rome"`
## Template Structure (`client/templates/base.html`) ## Template Structure (`src/frontend/flask_app/templates/base.html`)
Ordine blocchi in `base.html`: Ordine blocchi in `base.html`:
``` ```
@@ -127,7 +194,7 @@ In alternativa, usare il filtro custom `|tojson_attr` (registrato in `app.py`) c
Per selettori CSS in `x-data`: usare `meta[name=csrf-token]` senza virgolette interne. Per selettori CSS in `x-data`: usare `meta[name=csrf-token]` senza virgolette interne.
## Fabric.js Annotation Editor (`client/static/js/annotation-editor.js`) ## 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: Editor annotazioni su disegni tecnici (Fabric.js 5.3.1, ~1200 righe). Pattern critici:
@@ -152,16 +219,16 @@ Le ricette usano un versioning condizionale. L'endpoint `PUT /api/recipes/{id}`
La stessa logica si applica nel task router: aggiungere un task a una ricetta con measurements crea una nuova versione. 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`. 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 ### 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`: 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 UTL/LTL → **fail**
- Fuori UWL/LWL ma dentro UTL/LTL → **warning** - Fuori UWL/LWL ma dentro UTL/LTL → **warning**
- Dentro UWL/LWL → **pass** - Dentro UWL/LWL → **pass**
### SPC (Statistical Process Control) ### 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). 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 ### Autenticazione
1. Login con username/password → server ritorna `api_key` (64 char random) 1. Login con username/password → server ritorna `api_key` (64 char random)
@@ -187,7 +254,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
## Test Infrastructure ## Test Infrastructure
### Server (`server/tests/conftest.py`) ### Server (`src/backend/tests/conftest.py`)
- SQLite in-memory `sqlite+aiosqlite://` con `StaticPool` (singola connessione condivisa tra fixture, app e test) - 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()` - WeasyPrint mockato prima di qualsiasi import server: `sys.modules["weasyprint"] = MagicMock()`
- Rate limit buckets resettati tra test (walk middleware stack → clear dicts) - Rate limit buckets resettati tra test (walk middleware stack → clear dicts)
@@ -196,7 +263,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
- Helper: `auth_headers(user)``{"X-API-Key": user.api_key}` - Helper: `auth_headers(user)``{"X-API-Key": user.api_key}`
- Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency - Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency
### Client (`client/tests/conftest.py`) ### 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 - `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 - `logged_in_client` fixture pre-popola session con `api_key`, `user_id`, `language`, `theme` + user dict
- CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False` - CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False`
@@ -232,7 +299,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
- DB: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_ROOT_PASSWORD` (Docker) - 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` - Server: `SERVER_HOST`, `SERVER_PORT`, `SERVER_SECRET_KEY`, `SERVER_CORS_ORIGINS`
- Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL` - Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL`
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `server/`), `MAX_UPLOAD_SIZE_MB` - Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `src/backend/`), `MAX_UPLOAD_SIZE_MB`
- Docker: `NGINX_PORT`, `NGINX_SSL_PORT` - Docker: `NGINX_PORT`, `NGINX_SSL_PORT`
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato) - Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE` - SSL: `SSL_CERTFILE`, `SSL_KEYFILE`
+40
View File
@@ -0,0 +1,40 @@
FROM python:3.11-slim AS base
# Install uv (fast Python package manager) from official slim image.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
# System libs required by WeasyPrint at runtime.
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 lockfile + project metadata first to maximize Docker layer cache.
COPY pyproject.toml uv.lock ./
COPY .python-version ./
# Install ONLY backend deps from the locked dependencies. --frozen ensures
# we never resolve at build time; --no-dev keeps the image lean.
RUN uv sync --frozen --no-dev --extra server
# Now copy the actual sources.
COPY src/ ./src/
# Uploads directory (mounted as a volume in production).
RUN mkdir -p /app/uploads/images /app/uploads/pdfs /app/uploads/logos /app/uploads/reports
EXPOSE 8000
# Entry point: run Alembic migrations then start Uvicorn through uv so
# it uses the pinned interpreter and venv from `uv sync`.
CMD ["sh", "-c", \
"uv run alembic -c src/backend/migrations/alembic.ini upgrade head && \
uv run uvicorn src.backend.main:app \
--host 0.0.0.0 --port 8000 --workers 4 \
--proxy-headers --forwarded-allow-ips='*'"]
+48
View File
@@ -0,0 +1,48 @@
FROM python:3.11-slim AS base
# uv from the official slim image (fast Python package manager).
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
# Node.js 20 is needed at build time to compile TailwindCSS.
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& 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
# Resolve Python deps from the project lockfile (only the `client` extra).
COPY pyproject.toml uv.lock ./
COPY .python-version ./
RUN uv sync --frozen --no-dev --extra client
# Copy the Flask app sources.
COPY src/frontend/flask_app/ ./flask_app/
# Build TailwindCSS (one-shot; no watcher in production image).
#
# Versione esatta, non `tailwindcss@3`. Con il vincolo aperto ogni ricostruzione
# dell'immagine poteva prendere una minor diversa e produrre un CSS diverso senza
# che nessuno l'avesse validato: per un sistema che produce evidenze per audit
# ISO 9001 / IATF 16949 è lo stesso problema che il punto 12 risolve per le
# librerie del browser. Aggiornarla è una decisione, non un effetto collaterale.
WORKDIR /app/flask_app
RUN npm install tailwindcss@3.4.19 && \
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
# Compile Flask-Babel translation catalogs.
RUN uv run --project /app pybabel compile -d translations
EXPOSE 5000
# Gunicorn behind Nginx/Traefik. Worker count and proxy trust kept in sync
# with the per-tablet rate-limiting fix (see V2.0.0 perf commit).
CMD ["uv", "run", "--project", "/app", "gunicorn", \
"--workers", "5", \
"--threads", "4", \
"--worker-class", "gthread", \
"--timeout", "60", \
"--bind", "0.0.0.0:5000", \
"--access-logfile", "-", \
"--forwarded-allow-ips", "*", \
"app:create_app()"]
+41
View File
@@ -0,0 +1,41 @@
FROM python:3.13-slim
# opencv-python (pulled in transitively by vs-core/vs-task/vs-measure/vs-pm2d)
# needs libGL and glib at runtime even though it is never imported by name here.
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY vendor/ ./vendor/
RUN uv sync --extra vision-worker --frozen --no-dev
COPY src/vision/ ./src/vision/
COPY src/vision_worker/ ./src/vision_worker/
# Ruling R8: the version is stamped at build time, not discovered at runtime.
# COPY vendor/ above does not bring vendor/visionsuite/.git along - it is a
# gitlink file pointing at ../../.git/modules/vendor/visionsuite, which lives
# outside the build context - so `git rev-parse HEAD` cannot work in here.
# Pass the submodule's commit in from the host, which does have git:
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
# docker compose build vision
ARG VISION_ENGINE_VERSION
# I4: a build without the commit must fail now, at build time - not later, at
# every request including /health, with a container Docker still reports as
# healthy because no healthcheck existed to say otherwise.
RUN test -n "$VISION_ENGINE_VERSION" || { \
echo "VISION_ENGINE_VERSION is required. Build with:"; \
echo ' VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) docker compose build vision'; \
exit 1; \
}
ENV VISION_ENGINE_VERSION=${VISION_ENGINE_VERSION}
EXPOSE 8100
# Two workers, not four: each holds the vision stack in memory.
CMD ["uv", "run", "uvicorn", "src.vision_worker.main:app", \
"--host", "0.0.0.0", "--port", "8100", "--workers", "2"]
+314 -98
View File
@@ -1,6 +1,10 @@
# TieMeasureFlow by Tielogic # 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:** V3.0.0 (in sviluppo) — branch `V3.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).
> V3.0.0 lavora sui quindici punti di `TieMeasureFlow_modifiche_2026-07-28.md`: vedi [Novità di V3.0.0](#novità-di-v300).
--- ---
@@ -12,10 +16,39 @@ Caratteristiche principali:
- Recipe versioning condizionale (copy-on-write se esistono misurazioni, update in-place altrimenti) - Recipe versioning condizionale (copy-on-write se esistono misurazioni, update in-place altrimenti)
- Calcolo pass/fail/warning su quattro limiti di tolleranza (UTL, UWL, LWL, LTL) - Calcolo pass/fail/warning su quattro limiti di tolleranza (UTL, UWL, LWL, LTL)
- Tracciamento del tempo di inserimento per ogni misura (`input_duration_ms`): il client misura quanto tempo l'operatore impiega su ciascun subtask, dall'attivazione al salvataggio, utile per analisi di tempo ciclo e produttività
- SPC: Cp, Cpk, Pp, Ppk, control chart UCL/LCL, istogramma con curva normale — puro stdlib (no numpy) - 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 - 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 - 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)
---
## Novità di V3.0.0
V3.0.0 lavora sui quindici punti raccolti in `TieMeasureFlow_modifiche_2026-07-28.md`,
dal sopralluogo del 28/07. Quanto segue è **fatto e in esercizio**.
| # | Punto | Cosa cambia |
|---|---|---|
| 1 · 6 | **Produzione con una vita propria** | `production_runs` e `production_events` sul server: avvio, fermo linea, ripresa e fine produzione esistono come stato, non come pulsanti. La fine produzione emette il file di statistica dell'intera produzione |
| 2 | **Tipo di task dichiarato** | `task_type` (`note`, `measure`, `drawing`, `xf_compare`, `camera_measure`) al posto di «ha subtask, quindi è una misura»: un task di misura senza quote non si comporta più come una nota |
| 3 | **Ciclo di misura** | L'intervallo della ricetta vive sul server, sopravvive al cambio pagina, conta anche il ritardo (in rosso, oltre lo zero) e riporta l'operatore alla misura. Cicalino via WebAudio |
| 5 | **Fuori tolleranza vincolante** | Una quota fuori tolleranza blocca l'avanzamento finché il capoturno non autorizza — o finché quella stessa quota non viene rimisurata dentro i limiti. L'autorizzazione resta scritta sulla misura e nel CSV |
| 7 | **Stazioni** | Ricette per stazione, cambio stazione da URL per il collaudo, reset per riga |
| 8 | **Tracciabilità obbligatoria** | `requires_lot` / `requires_serial` per ricetta, verificati sul server: nessuna porta d'ingresso li aggira, barcode compreso |
| 9 | **Blocco inserimento manuale** | `allow_manual_input`: con la ricetta a solo calibro il tastierino non viene disegnato affatto, e il server rifiuta comunque un valore digitato a mano |
| 10 | **Sequenza per l'operatore** | La ricetta si apre sul primo task, non su un elenco. La lista scende a secondo livello e dice quali task sono rimasti incompiuti (`2/3`). «Fine ciclo misura» è visibile da subito e spento finché mancano quote |
| 11 | **Descrizioni formattate** | `**grassetto**` e a capo nelle descrizioni dei task, con marcatura e non HTML: la sanificazione è per costruzione |
| 12 | **Funzionamento senza internet** | Tutte le librerie e i font nell'installazione, versioni congelate e verificate per impronta, Content-Security-Policy a sola origine locale |
| 14 | **Layout stabile** | Una cornice sola per tutte le viste, allineata alla navbar; spazio della barra di scorrimento sempre riservato; schermata di misura su `100dvh`. Vedi [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) |
| 15 | **Statistica separata** | Si registra sempre, si consulta a parte: nessun percorso dell'operatore porta alla statistica (verificato da test) |
**Aperto**: il punto 4 (numero di tentativi prima del capoturno) attende le risposte
del cliente su modalità di autorizzazione e numero di tentativi; il punto 13
(generazione task dalla scheda tecnica con l'AI) è fuori offerta.
--- ---
@@ -26,25 +59,25 @@ Browser/Tablet
| |
Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione) Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione)
| |
Flask Client :5000 (rendering server-side, Jinja2 + Alpine.js) Flask Frontend :5000 (rendering server-side, Jinja2 + Alpine.js)
| X-API-Key | X-API-Key + X-Forwarded-For
FastAPI Server :8000 (API REST asincrona) FastAPI Backend :8000 (API REST asincrona)
| |
MySQL 8.0 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 ## Stack Tecnologico
### Backend (server/) ### Backend (`src/backend/`)
| Componente | Versione | Ruolo | | Componente | Versione | Ruolo |
|---|---|---| |---|---|---|
| FastAPI | ultima stabile | Framework API REST asincrono | | 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 | | asyncmy | ultima stabile | Driver MySQL asincrono |
| MySQL | 8.0 | Database relazionale | | MySQL | 8.0 | Database relazionale |
| Alembic | ultima stabile | Migrazioni schema | | Alembic | ultima stabile | Migrazioni schema |
@@ -54,35 +87,56 @@ Il client Flask non espone mai le credenziali al browser: ogni chiamata al backe
| bcrypt | ultima stabile | Hashing password | | bcrypt | ultima stabile | Hashing password |
| Pillow | ultima stabile | Thumbnail automatici upload | | Pillow | ultima stabile | Thumbnail automatici upload |
### Frontend (client/) ### Frontend (`src/frontend/flask_app/`)
| Componente | Versione | Ruolo | | Componente | Versione | Ruolo |
|---|---|---| |---|---|---|
| Flask | 3.x | Framework web server-side | | Flask | 3.x | Framework web server-side |
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
| Jinja2 | incluso in Flask | Template engine | | Jinja2 | incluso in Flask | Template engine |
| Alpine.js | 3.x (CDN) | Reattivita leggera lato client | | Alpine.js | 3.15.12 (locale) | Reattività leggera lato client |
| TailwindCSS | 3.x | CSS utility-first | | TailwindCSS | 3.4.19 (build) | CSS utility-first, compilato nell'immagine |
| Plotly.js | CDN | Grafici SPC interattivi | | Plotly.js | 2.32.0 (locale) | Grafici SPC interattivi |
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici | | PDF.js | 3.11.174 (locale) | Visualizzazione disegni PDF, worker incluso |
| Fabric.js | 5.3.1 (locale) | Editor annotazioni disegni tecnici |
| Inter + JetBrains Mono | woff2 locali | Font UI e numeri |
| Flask-Babel | ultima stabile | i18n IT/EN | | Flask-Babel | ultima stabile | i18n IT/EN |
**Nessuna libreria arriva dalla rete.** Tutte stanno in
[`src/frontend/flask_app/static/vendor/`](src/frontend/flask_app/static/vendor/VERSIONS.md)
con versione nel nome e impronta SHA-256 verificata da un test: l'installazione a
Tràfilo è su rete di produzione isolata, dove una pagina che aspetta un CDN è una
pagina bianca. Una Content-Security-Policy a sola origine locale (`app.py`) fa sì che
un tag verso l'esterno aggiunto in futuro venga rifiutato alla scrivania, non in
reparto.
### 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 ## 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 ```bash
# 1. Clona il repository # 1. Clona il repository
git clone <repository-url> git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
cd TieMeasureFlow cd TieMeasureFlow
# 2. Configura le variabili d'ambiente # 2. Configura le variabili d'ambiente
cp .env.example .env 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) # 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 # 4. Verifica lo stato dei container
docker compose -f docker-compose.dev.yml ps docker compose -f docker-compose.dev.yml ps
@@ -90,6 +144,7 @@ docker compose -f docker-compose.dev.yml ps
# 5. Setup iniziale (solo al primo avvio) # 5. Setup iniziale (solo al primo avvio)
# Apri http://localhost/api/setup nel browser # Apri http://localhost/api/setup nel browser
# Usa SETUP_PASSWORD configurata in .env # 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: L'applicazione sarà disponibile su:
@@ -97,8 +152,9 @@ L'applicazione sarà disponibile su:
- Frontend: http://localhost - Frontend: http://localhost
- API: http://localhost/api - API: http://localhost/api
- Pagina setup: http://localhost/api/setup - 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 +164,14 @@ Dopo il primo avvio, la pagina `/api/setup` (protetta da `SETUP_PASSWORD`) perme
- **Initialize Database** — crea tutte le tabelle - **Initialize Database** — crea tutte le tabelle
- **Create Admin User** — crea l'utente amministratore con credenziali da `.env` - **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) - **Reset Database** — elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati)
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina - **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato. 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) ## Setup Manuale (Senza Docker)
@@ -121,8 +179,9 @@ Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
### Requisiti ### Requisiti
- Python 3.11 o superiore - Python 3.11 o superiore
- Node.js 18 o superiore - Node.js 18 o superiore (per TailwindCSS)
- MySQL 8.0 - MySQL 8.0
- [uv](https://docs.astral.sh/uv/) installato
### 1. Database MySQL ### 1. Database MySQL
@@ -139,35 +198,37 @@ SQL
```bash ```bash
cp .env.example .env 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 ```bash
cd server uv sync --extra server --extra client --extra dev
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
``` ```
### 4. Client Flask ### 4. Backend FastAPI
```bash ```bash
cd client uv run alembic -c src/backend/migrations/alembic.ini upgrade head
python -m venv venv uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
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
``` ```
### 5. TailwindCSS (watch per sviluppo) ### 5. Frontend Flask
```bash ```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 npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
``` ```
@@ -185,9 +246,10 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
| Ruolo | Descrizione | | 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 | | **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 | | **Supervisor** (capoturno) | Autorizza ciò che l'operatore non può decidere da solo: una quota fuori tolleranza che deve restare, il fermo linea, la fine produzione. L'autorizzazione resta scritta sulla misura (`supervisor_id`, `authorised_at`) e finisce nel file di statistica |
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account | | **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart. **L'operatore non raggiunge la statistica da nessun percorso** |
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
--- ---
@@ -195,39 +257,61 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
``` ```
TieMeasureFlow/ TieMeasureFlow/
├── server/ # FastAPI Backend ├── pyproject.toml # Dipendenze monorepo (uv)
│ ├── main.py # Entry point, lifespan, middleware, 10 router ├── uv.lock # Lockfile riproducibile
│ ├── config.py # Settings (pydantic_settings.BaseSettings) ├── .python-version # 3.11
│ ├── database.py # SQLAlchemy 2.0 async engine ├── Dockerfile # Backend (uv + uvicorn)
│ ├── models/ # ORM: User, Recipe, RecipeVersion, RecipeTask, ├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
│ │ # RecipeSubtask, Measurement, AccessLog, ├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
│ │ # SystemSetting, RecipeVersionAudit ├── docker-compose.yml # Produzione (Traefik, SSL)
│ ├── schemas/ # Pydantic v2 per validazione I/O API ├── nginx/ # Config Nginx (dev)
│ ├── routers/ # auth, users, recipes, tasks, measurements, ├── uploads/ # Volume Docker file caricati
│ │ # files, settings, statistics, reports, setup ├── scripts/ # Script del progetto (seed ricette di collaudo)
│ ├── services/ # recipe_service, measurement_service, ├── docs/ # Documentazione (vedi indice docs/README.md)
│ │ # spc_service, report_service, auth_service └── src/
├── middleware/ # api_key, rate_limit, security_headers, logging ├── backend/ # FastAPI Backend
│ ├── migrations/ # Alembic (alembic.ini + env.py) │ ├── main.py # Entry point, lifespan, middleware, 11 router
│ └── templates/ # Template HTML pagina setup │ ├── config.py # Settings (pydantic_settings.BaseSettings)
├── client/ # Flask Frontend │ ├── database.py # SQLAlchemy 2.0 async engine
│ ├── app.py # Factory pattern, CSRF, Babel │ ├── api/
│ ├── blueprints/ # auth, maker, measure, statistics, admin │ │ ├── routers/ # auth, users, recipes, tasks, measurements,
│ ├── services/ # APIClient singleton (proxy verso FastAPI) │ │ │ # files, settings, statistics, reports,
│ ├── templates/ # Jinja2 + Alpine.js │ │ │ # setup, stations
│ ├── static/ │ │ └── middleware/ # api_key, rate_limit, security_headers, logging
│ ├── css/ # TailwindCSS compilato │ ├── models/
│ │ ── js/ # numpad, caliper, barcode, csv-export, │ │ ── orm/ # SQLAlchemy: User, Recipe, RecipeVersion,
│ │ # spc-charts, annotation-editor/viewer │ │ # RecipeTask, RecipeSubtask, Measurement,
│ └── translations/ # Flask-Babel .po/.mo IT/EN │ │ │ # AccessLog, SystemSetting,
├── nginx/ # Configurazione Nginx (dev) │ │ │ # RecipeVersionAudit, Station,
├── docs/ # Documentazione tecnica # StationRecipeAssignment,
│ ├── API.md # Riferimento API REST │ │ │ # ProductionRun, ProductionEvent
│ ├── DEPLOYMENT.md # Guida deployment VPS (Traefik, SSL, DNS) │ │ └── api/ # Pydantic v2 schemas request/response
── USER_GUIDE.md # Manuale utente per ruolo ── services/ # recipe_service, measurement_service,
├── docker-compose.yml # Produzione (Traefik, SSL) │ │ # spc_service, report_service,
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80) │ │ # auth_service, station_service,
└── .env.example # Template variabili d'ambiente │ │ # production_service,
│ │ # production_export_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 + themes.css
│ │ # (cornice pagina, temi, scrollbar)
│ ├── js/ # numpad, caliper, barcode, csv-export,
│ │ # spc-charts, annotation-editor/viewer,
│ │ # production-clock, rich-text
│ └── vendor/ # Alpine, Plotly, PDF.js (+worker), Fabric,
│ # font woff2 — vedi VERSIONS.md
├── translations/ # Flask-Babel .po/.mo IT/EN
└── tests/
``` ```
--- ---
@@ -238,7 +322,7 @@ TieMeasureFlow/
| Comando | Descrizione | | 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 -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 server` | Segui log server in tempo reale |
| `docker compose logs -f client` | Segui log client in tempo reale | | `docker compose logs -f client` | Segui log client in tempo reale |
@@ -248,47 +332,153 @@ TieMeasureFlow/
### Alembic (migrations) ### 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 ```bash
cd server uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
alembic -c migrations/alembic.ini upgrade head # Applica migrazioni uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback ultima
alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
``` ```
Via Docker: Via Docker:
```bash ```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) ### i18n (Traduzioni)
```bash ```bash
cd client cd src/frontend/flask_app
pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai stringhe uv run --project ../../.. pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai
pybabel update -i translations/messages.pot -d translations # Aggiorna catalogo uv run --project ../../.. pybabel update -i translations/messages.pot -d translations # Aggiorna
pybabel compile -d translations # Compila .po → .mo 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 ## 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 ```bash
# Server # Tutti i test (backend + frontend)
cd server && pytest # Tutti i test uv run pytest
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 # Solo backend
cd client && pytest 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/
``` ```
Il modulo di visione (`src/vision/`) è un albero a parte, con un vincolo di
versione diverso dal resto del monorepo: le librerie di VisionSuite da cui
dipende richiedono Python 3.13 o superiore, mentre backend e frontend restano
su Python 3.11. Per questo motivo l'esecuzione di `uv run pytest` con
l'interprete di default mostra i test di `src/vision/tests/` come *skipped*,
non come falliti: il pacchetto viene rilevato ma la sua esecuzione reale
richiede un ambiente Python 3.13 dedicato, creato con il proprio extra `uv`.
Per eseguirli davvero:
```bash
# Prepara l'ambiente Python 3.13 con le dipendenze di visione
uv sync --extra vision --extra dev --python 3.13
# Esegue i test del runner di visione in quell'ambiente
uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests
```
`src/vision_worker/tests/` eredita lo stesso vincolo su Python 3.13, perché
il worker dipende da `src.vision.runner`, ma aggiunge dipendenze proprie -
FastAPI, Uvicorn, Pillow, python-multipart - dichiarate in un extra
separato, `vision-worker`, tenuto distinto da `vision` apposta: un
consumatore che incorpora solo il runner (come un futuro agente di stazione,
che non è un servizio web) non deve trascinarsi dietro un server che non gli
serve. `vision-worker` include comunque `vision`, quindi un solo extra basta
per avere un worker funzionante:
```bash
uv run --python 3.13 --extra vision-worker --extra dev pytest src/vision_worker/tests
```
Per eseguire entrambe le suite insieme, nominando esplicitamente entrambi
gli extra:
```bash
uv run --python 3.13 --extra vision --extra vision-worker --extra dev pytest src/vision/tests src/vision_worker/tests
```
### Il worker di visione (`Dockerfile.vision`)
Il worker gira in un container separato dal server FastAPI principale, così
che l'immagine dell'API resti leggera e un aggiornamento di VisionSuite non
richieda di riavviare il traffico di produzione. Il container non pubblica
porte verso l'esterno: lo raggiunge solo il server, all'indirizzo interno
`http://vision:8100` sulla rete `tmflow-net`.
Ogni misura riporta il commit di VisionSuite che l'ha prodotta
(`engine_version`), perché una stazione che misura con un motore diverso da
quello atteso deve poter essere identificata. Il container, però, non ha
accesso al repository Git del progetto principale — `vendor/visionsuite` è un
submodule, e la copia `.git` che lo collega al repository ospitante non
viene inclusa nel contesto di build — quindi la versione non può essere
scoperta al volo dentro l'immagine. Va invece **stampata al momento del
build**, leggendo il commit dalla macchina che esegue `docker compose build`,
dove il repository Git è presente per intero:
```bash
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
docker compose -f docker-compose.dev.yml build vision
```
La variabile viene passata come build argument (`ARG VISION_ENGINE_VERSION`
in `Dockerfile.vision`) e fissata nell'immagine come variabile d'ambiente, in
modo che il worker la trovi già pronta a ogni avvio senza doverla ricalcolare.
Fuori da un container, in un checkout di sviluppo locale, la stessa funzione
ricade su `git rev-parse` se la variabile non è impostata — comodo per
lavorare sul runner senza Docker. Ma se un'immagine viene costruita senza
passare `VISION_ENGINE_VERSION`, quel ripiego non ha nulla su cui appoggiarsi:
il `.git` del submodule non arriva nel contesto di build, e il worker
risponde con un errore esplicito su `/health` invece di indovinare una
versione o restituire `"unknown"`. La build va quindi sempre lanciata con la
variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando
mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml`
in produzione).
Stato corrente su `V3.0.0`, con `uv run pytest` su Python 3.11 e senza
l'extra `vision` (un conteggio senza il suo ambiente non dice nulla, regola
che vale anche qui): **390 pass, 1 fail, 4 skip** — 243 in `src/backend/tests/`,
147 pass + 2 skip in `src/frontend/flask_app/tests/`, più 2 skip in
`src/vision/tests/` e `src/vision_worker/tests/` per il vincolo su Python
3.13 descritto sopra. Il fallimento è preesistente e indipendente da questo
lavoro: `test_offline.py::test_no_first_party_script_calls_out`, dovuto a una
copia locale di Fabric.js non tracciata da git (`static/js/fabric-debug.js`)
che nei propri commenti cita pagine di documentazione esterne.
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
proprietà che sopravvivono solo se qualcuno le controlla:
| File | Cosa impedisce |
|---|---|
| `test_offline.py` | Una libreria caricata dalla rete, un worker PDF.js lasciato sul CDN, una libreria sostituita senza aggiornare l'impronta |
| `test_layout_shell.py` | Una vista che torna a dichiararsi la propria larghezza |
| `test_template_js_syntax.py` | Una traduzione con l'apostrofo dentro una stringa JS a virgolette singole, che spegne Alpine su tutta la pagina |
--- ---
## Variabili d'Ambiente ## Variabili d'Ambiente
@@ -304,8 +494,12 @@ Copia `.env.example` in `.env` e configura:
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse | | `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) | | `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) | | `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, relativo a `server/`) | | `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload | | `VISION_WORKER_URL` | Indirizzo interno del worker di visione (default: `http://vision:8100`, mai esposto fuori da `tmflow-net`) |
| `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) | | `NGINX_PORT`, `NGINX_SSL_PORT` | Porte Nginx (solo compose dev) |
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) | | `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) | | `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
@@ -314,16 +508,38 @@ Copia `.env.example` in `.env` e configura:
## Documentazione ## Documentazione
Indice completo: [`docs/README.md`](docs/README.md).
### Stato e direzione
| Documento | Contenuto | | Documento | Contenuto |
|---|---| |---|---|
| [docs/API.md](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) | | [`TieMeasureFlow_modifiche_2026-07-28.md`](TieMeasureFlow_modifiche_2026-07-28.md) | I quindici punti del 28/07: cosa deve cambiare, dove intervenire, decisioni in attesa del cliente (D-1…D-9) |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall | | [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) | | [`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) |
| [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava e la regola che lo tiene fermo |
| [`src/frontend/flask_app/static/vendor/VERSIONS.md`](src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte, come aggiornarle |
### 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 ## 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. Questo software è di proprietà esclusiva di Tielogic ed è protetto dalle leggi sul copyright. Non è consentita la distribuzione, modifica o utilizzo senza autorizzazione scritta.
+614
View File
@@ -0,0 +1,614 @@
# TieMeasureFlow — modifiche da apportare
**Data:** 28 luglio 2026 (agg. serale: architettura d'installazione, punti 7-8 precisati, punti 14-15 nuovi, timer bidirezionale nel punto 3)
**Riferimento codice:** branch `V2.0.0`, commit `2a56632`
**Destinatari:** Parte 1 leggibile da cliente e rivenditore · Parte 2 per chi mette mano al codice
---
## Come leggere questo documento
La **Parte 1** dice *cosa deve fare* il sistema e *perché*: si può mandare a Ricerca e
Misure e a Tràfilo. La **Parte 2** dice *dove si interviene*, con file e criteri di
completamento. La **Parte 3** elenca le domande la cui risposta non dipende da noi.
Ogni punto porta lo **stato verificato sul codice**, non riferito: `già fatto`,
`parziale`, `da fare`. Serve a evitare l'equivoco emerso in questi giorni — diverse
funzioni risultano "già implementate" perché **l'interfaccia c'è**, ma dietro non
succede nulla.
**Fonti:** specifica *Schema sviluppo SW TieFlow rev. 04-2026*; *MODIFICHE TIEMEASURE
v260519*; call del 28/07/2026; rettifica di Marco Menoncin del 28/07; lettura diretta
del codice.
---
## Il punto che regge tutti gli altri
**Lo stato della produzione oggi vive dentro una pagina del browser.**
Il timer, il conteggio dei cicli, il flag "produzione avviata" e le misure in corso
sono variabili del componente Alpine di `task_execute.html`. La navigazione fra un
task e l'altro è un **ricaricamento completo di pagina** (`window.location.href`).
Conseguenza diretta: **cambiando task si perde tutto.** Il timer smette di esistere,
il conteggio dei cicli riparte, "produzione avviata" si dimentica.
Questo spiega, senza bisogno di altre ipotesi:
- perché il loop di misura non regge quando la ricetta ha altri task dopo la misura;
- perché con più task di misura non si sa a quale tornare allo scadere del timer;
- perché *fermo linea* e *fine produzione* non hanno nulla da fermare o da chiudere;
- perché non esiste storico di cosa è successo durante una produzione.
**Va risolto per primo**: i punti 2, 3, 6 e 8 dipendono da questa scelta, e affrontarli
prima significa rifarli dopo.
---
## Architettura d'installazione (decisa il 28/07)
Chiude la domanda D-3 lato Tielogic. L'installazione a Tràfilo è così composta:
```
SERVER (uno, del cliente) STAZIONE (una per PC, installata in locale)
┌─────────────────────────┐ ┌──────────────────────────────────────┐
│ MySQL │◄──API──│ App di stazione: │
│ Backend dati (API) │ │ · interfaccia operatore (frontend) │
│ File dei disegni │ │ · agente hardware: calibro USB, │
│ Migrazioni dello schema │ │ driver camera, colonnina/cicalino │
│ Adattatore GAIA │ │ · elaborazione visione (futura: │
└─────────────────────────┘ │ XF compare, pattern matching) │
└──────────────────────────────────────┘
```
- **Sul server vive tutto ciò che è stato**: il database, i file dei disegni, le
migrazioni dello schema e l'unico punto di contatto col gestionale GAIA.
Aggiornare la logica dati = un deploy, in un posto solo.
- **Sulla stazione vive tutto ciò che tocca l'hardware**: calibro, telecamera,
segnalazione luminosa/acustica, e in prospettiva l'elaborazione visione — che
così non carica il server (rilevante per il dimensionamento, D-4).
L'app di stazione è **senza stato**: non tocca il database direttamente, parla
col backend dati via API e gli manda risultati e immagini.
- **L'identità della stazione è data dall'installazione** (configurata sul PC),
non più dal container sul server: il vincolo "una stazione = un container"
decade.
- La **licenza per postazione** ha l'aggancio naturale nell'app di stazione
installata.
**Correzione a quanto detto in call:** i disegni **non stanno nel database**
stanno su filesystem (`uploads/`), serviti dal backend. Con questa architettura
restano sul server, in un posto solo: nessuna cartella da sincronizzare fra i PC.
Conseguenza per lo sviluppo: lo stato della produzione (punto 1) va nel database
**per obbligo architetturale**, non per scelta — con 20 app di stazione non esiste
altro posto dove possa vivere.
---
# Parte 1 — Cosa deve fare il sistema
## Quadro: cosa già funziona
Da mettere a verbale, perché in parte era dato per mancante:
| Funzione | Stato |
|---|---|
| Stazioni e assegnazione ricette alle stazioni | ✅ presente |
| Ruolo **capoturno** con autorizzazione a login | ✅ presente |
| Timer d'intervallo misura con **conto alla rovescia a video** e cicalino | ✅ presente |
| Logout automatico per inattività, configurabile | ✅ presente |
| Tolleranze con soglie di attenzione (UTL/UWL/LWL/LTL) ed esito per quota | ✅ presente |
| Versionamento delle ricette con storico delle modifiche | ✅ presente |
| Registrazione del **tempo di inserimento** di ogni misura | ✅ presente (28/07) |
| Pulsanti *Fermo linea*, *Fine produzione*, *Avvio produzione* | ⚠️ solo interfaccia |
L'ultima riga è la più importante: i tre pulsanti esistono, chiedono correttamente
l'autorizzazione del capoturno, **e poi non fanno nulla**.
---
## 1. Memoria della produzione in corso `da fare` · prerequisito
**Oggi:** lo stato di una produzione esiste solo finché l'operatore resta sulla stessa
schermata.
**Deve:** la produzione diventa un'entità con una vita propria — si apre quando parte,
registra ciò che accade (avvio, cicli di misura, fermo linea, ripresa, chiusura) e si
chiude quando il capoturno la chiude. Se l'operatore cambia task, esce e rientra, o il
tablet si riavvia, la produzione è ancora lì con il suo timer.
**Perché conta:** senza questo, *fermo linea* e *fine produzione* non possono
funzionare, il loop di misura non è realizzabile e non esiste il dato storico su cui
poggiano sia la statistica sia l'integrazione col gestionale.
---
## 2. Tipo di task esplicito `da fare`
**Oggi:** il sistema distingue un task di misura da un task documentale **deducendolo**:
se ha delle quote è una misura, altrimenti è una nota. Non esiste un tipo dichiarato.
**Deve:** ogni task nasce con un tipo scelto da chi crea la ricetta — **nota**,
**misura**, **disegno** — predisposto per i tipi futuri (confronto profilo/DXF, misura
con camera).
**Perché conta:** è la richiesta n. 1 di Menoncin, ed è ciò a cui si agganciano timer e
loop. Con la deduzione attuale un task di misura a cui non sono ancora state inserite le
quote viene trattato come una nota: il sistema si comporta in modo diverso a seconda di
quanto è completa la ricetta.
---
## 3. Loop di misura e ripetizione `parziale`
**Oggi:** allo scadere del timer suona il cicalino e il ciclo si riazzera — ma solo se
l'operatore è rimasto su quella schermata.
**Deve:**
- l'operatore esegue i task in sequenza fino al primo task di misura;
- da lì **resta in loop sulla misura** finché la produzione non viene chiusa;
- il conto alla rovescia di **quanto manca alla prossima misura** è sempre visibile;
- **arrivato a zero, il contatore riparte nell'altro senso**: mostra da quanto tempo
si è **oltre** l'intervallo di misura, in evidenza — così il ritardo si vede, non
si deduce (richiesta dell'operatore, 28/07);
- allo scadere del timer la misura **si ripropone** ovunque si trovi l'operatore;
- se la ricetta ha **più task di misura**, il timer riparte alla fine dell'**ultimo**;
- dev'essere possibile **girare il pezzo e rimisurare** senza chiudere il ciclo.
**Perché conta:** è la seconda richiesta di Menoncin e il comportamento descritto al
punto 4.10 della specifica del 19/05. La logica c'è già in gran parte: manca che
sopravviva al cambio di schermata (punto 1).
---
## 4. Limite di tentativi prima del capoturno `da fare`
**Oggi:** l'operatore può ripetere la misura quante volte vuole.
**Deve:** dopo un numero di tentativi definito nella ricetta, per proseguire serve
l'autorizzazione del capoturno.
**Perché conta:** è nella specifica ed è la contromisura al caso in cui si ripete finché
non "viene bene". Il meccanismo di autorizzazione esiste già: manca il contatore e la
soglia.
---
## 5. Avanzamento solo se in tolleranza `da verificare`
**Deve:** si passa alla quota successiva in autonomia **solo se la quota è in
tolleranza**; per confermare una quota fuori tolleranza serve il capoturno.
**Nota:** il gate del capoturno per il fuori tolleranza è implementato. Va verificato
sul campo che **blocchi davvero l'avanzamento** e non sia solo una richiesta di conferma.
Se blocca, il punto si chiude senza sviluppo.
---
## 6. Fermo linea e Fine produzione: dare effetto `parziale`
**Oggi:** entrambi chiedono l'autorizzazione del capoturno, poi non succede niente.
**Deve:**
- **Fermo linea** — sospende il timer e la produzione; il capoturno può riattivarla;
- **Fine produzione** — chiude la produzione, ferma il timer definitivamente e **invia
i dati di misura dell'intera produzione** al file di statistica.
**Perché conta:** il comportamento verso il gestionale è ancora da definire (Parte 3),
ma **tutto ciò che sta prima del gestionale si può e si deve fare adesso**: sospendere,
riprendere, chiudere, registrare. Consegnare i pulsanti funzionanti senza il gestionale
è possibile; il contrario no.
---
## 7. Gestione delle stazioni `parziale`
**Deve:**
- la lista stazioni mostra, oltre a codice e postazione, **le ricette collegate**;
- esiste un **reset della stazione**, con un pulsante **per riga** nella lista:
la stazione torna senza ricette associate e si riassegna (precisazione
dell'operatore, 28/07);
- la **stazione corrente si può cambiare al volo**, per poter provare più stazioni da un
solo computer senza riconfigurare l'installazione.
**Perché conta:** è la terza richiesta di Menoncin. Il cambio al volo non è un vezzo da
sviluppatori: senza, la sessione di collaudo con Menoncin richiede tanti PC quante sono
le stazioni da provare.
---
## 8. Tracciabilità obbligatoria `parziale`
**Oggi:** numero di lotto e numero seriale sono facoltativi e si inseriscono nella lista
task, cioè dopo aver iniziato.
**Deve:** l'obbligatorietà di lotto e seriale si **decide alla creazione della
ricetta** (obbligatori sì/no); l'operatore li inserisce **alla selezione della
ricetta**, e finché mancano il pulsante *Avvia* **non si attiva** (precisazione
dell'operatore, 28/07).
**Perché conta:** una misura senza lotto non è tracciabile a posteriori, e la
tracciabilità è metà del valore del sistema in un audit.
---
## 9. Blocco dell'inserimento manuale `da fare`
**Oggi:** il sistema registra **come** è stata inserita una misura (calibro o tastiera),
ma accetta sempre entrambi.
**Deve:** un'impostazione della ricetta consente o vieta l'inserimento manuale, **con
divieto come impostazione predefinita**: si misura col calibro.
**Perché conta:** è il punto sollevato in call — senza questo vincolo un valore in
tolleranza si può digitare. Il dato su *come* è stata inserita c'è già: manca la regola
che lo impedisce.
---
## 10. Interfaccia operatore: sequenza e conferme `da fare`
Richieste del 19/05, tutte di interfaccia:
- un pulsante che **avvia i task in sequenza**, senza sceglierli a uno a uno;
- la lista completa dei task retrocessa a **secondo livello**, per tornare a vedere i
task precedenti;
- «inizia misure» rinominato **«visualizza singolo TASK»**;
- dentro il task, «Riepilogo» sostituito da **«Completato»** per passare al successivo;
- un task lasciato a metà **resta incompiuto** e si vede;
- «fine ciclo misura» **cliccabile solo quando tutte le quote hanno un valore**.
---
## 11. Formattazione delle descrizioni `da fare`
**Deve:** le descrizioni dei task accettano andate a capo e grassetto.
**Perché conta:** chi crea le ricette fa **copia e incolla dal PDF della scheda
tecnica**; oggi il testo arriva appiattito e va risistemato a mano ogni volta.
---
## 12. Funzionamento senza internet `da fare` · bloccante per l'installazione
**Oggi:** l'applicazione **non funziona senza collegamento a internet**. Cinque librerie
vengono scaricate al volo da servizi esterni ogni volta che si apre una pagina.
**In una rete di produzione isolata — la norma in fabbrica — il risultato è una pagina
bianca.** Non un degrado: l'interfaccia non parte proprio, e senza le altre non si vedono
i disegni tecnici né i grafici statistici.
**Deve:** tutte le librerie sono incluse nell'installazione e l'applicazione funziona a
rete staccata.
**Perché conta ora:** l'installazione a Tràfilo è on-premise e prevista per settembre.
È poco lavoro, ma va fatto **prima**, non in fabbrica il giorno dell'installazione.
**Beneficio collaterale non ovvio:** oggi una delle librerie è agganciata a una versione
"qualunque della serie 3" — cioè **l'applicazione cambia da sola** quando gli autori
pubblicano un aggiornamento, senza che nessuno l'abbia validata. Per un sistema che
produce evidenze per audit ISO 9001 / IATF 16949 questo è di per sé un problema.
Includendo le librerie le versioni si congelano: da difetto diventa argomento di vendita.
---
## 13. Generazione dei task dalla scheda tecnica con l'AI `fuori offerta`
**Richiesta:** leggere il PDF della scheda tecnica e **creare un task per blocco**,
invece del copia-incolla manuale.
**Storia:** posta il **19/05/2026** nel documento delle modifiche, rimasta senza
risposta; **rilanciata da Tràfilo il 28/07** come elaborazione massiva iniziale delle
schede, «senza installare agenti nel sistema».
**Stato:** non è in nessuna offerta. Prima di quotare servono tre informazioni: quante
sono le schede, se il formato è standard, e se l'elaborazione è una-tantum in fase di
avviamento o una funzione permanente del prodotto. Sono domande da fare, non da
supporre — vedi Parte 3.
---
## 14. Stabilità del layout `da fare` · da circoscrivere
**Oggi:** le dimensioni delle viste **cambiano a seconda del menu**: passando da una
schermata all'altra la finestra non mantiene proporzioni stabili.
**Deve:** il layout resta stabile nel passaggio fra le viste.
**Nota:** segnalazione dell'operatore del 28/07, non ancora circoscritta sul codice —
prima di intervenire va riprodotta e va stilato l'elenco delle viste interessate.
---
## 15. Statistica: si registra sempre, si consulta a parte `già fatto` · da confermare sul campo
**Richiesta (28/07):** a fine misura l'operatore **non va portato nella pagina della
statistica**. I dati **entrano comunque in statistica**: cambia solo chi la consulta —
serve l'**utente con il ruolo adeguato**, che apre la pagina dedicata.
**Verificato sul codice:** è già così. Tutte le pagine di statistica richiedono il
ruolo **Metrologo** (`role_required("Metrologist")` su ogni route), e a fine ciclo
l'operatore viene portato al **riepilogo**, non alla statistica.
**Resta da fare:** niente sviluppo; il requisito entra come **criterio di collaudo**
(l'operatore non deve poter raggiungere la statistica da nessun percorso) e va tenuto
fermo quando il punto 10 ridisegna la navigazione a fine task.
---
# Parte 2 — Dove si interviene
Riferimenti al branch `V2.0.0`, commit `2a56632`.
## Ordine consigliato
```
12 (offline) ──────────────► indipendente, si può fare subito
bloccante per l'installazione
1 (stato produzione) ──┬───► 3 (loop misura)
├───► 6 (fermo linea / fine produzione)
└───► 4 (limite tentativi)
2 (tipo task) ─────────────► 3, 10
7 (stazioni) · 8 (tracciabilità) · 9 (inserimento manuale) · 11 (formattazione)
indipendenti fra loro
5 (avanzamento in tolleranza) ──► prima verificare, forse è già a posto
14 (layout) ────────────────► prima riprodurre e circoscrivere le viste
15 (statistica riservata) ──► già a posto: solo criterio di collaudo,
da non rompere lavorando sul punto 10
```
La separazione **backend dati sul server / app di stazione in locale** (vedi
*Architettura d'installazione*) non è un punto di questa lista: è il contesto in
cui i punti 1, 3, 6 e 7 vanno progettati. In pratica: API senza stato, stato solo
nel database, niente dipendenze dal container per l'identità della stazione.
## 1 · Stato della produzione lato server
**Problema tecnico:** `task_execute.html` tiene in variabili Alpine
(`timerActive`, `timerRemaining`, `_timerInterval`, `cycleCount`, `cycleConfirmed`,
`productionStarted`, `measurements`) uno stato che deve sopravvivere alla pagina.
`goToNextTask()` fa `window.location.href` → il componente viene distrutto.
Lato server la sessione conserva soltanto `lot_number` e `serial_number`.
**Intervento:**
- nuove tabelle `production_runs` e `production_events` (avvio, ciclo completato, fermo
linea, ripresa, chiusura), con `station_id`, `recipe_version_id`, `operator_id`,
`supervisor_id` dove serve, timestamp;
- endpoint REST per aprire, interrogare e aggiornare la produzione corrente della
stazione;
- il frontend legge lo stato all'apertura di ogni pagina invece di tenerlo in memoria;
il conto alla rovescia si **ricalcola dall'orario di scadenza** salvato lato server,
non da un contatore locale;
- gli endpoint vanno progettati **senza stato in memoria di processo**: con l'app di
stazione installata su ogni PC (vedi *Architettura d'installazione*) il database è
l'unico posto condiviso.
**File:** `src/backend/models/orm/` (nuovo modulo), `src/backend/migrations/versions/`
(migrazione 005), `src/backend/api/routers/`, `src/frontend/flask_app/blueprints/measure.py`,
`src/frontend/flask_app/templates/measure/task_execute.html`.
**Fatto quando:** avviata una produzione, si naviga fra i task, si esce e si rientra, e
il timer prosegue coerente; il riavvio del browser non azzera nulla.
---
## 2 · Tipo di task
**Problema tecnico:** `RecipeTask` non ha campo tipo; il frontend decide con
`subtasks.length > 0` (`task_execute.html`, `task_list.html`).
**Intervento:** campo `type` su `recipe_tasks` con valori `note | measure | drawing`
(predisposto per `xf_compare`, `camera_measure`), migrazione con valorizzazione dei dati
esistenti secondo la regola attuale, selezione del tipo nell'editor ricetta,
sostituzione dei controlli su `subtasks.length` con il tipo.
**File:** `src/backend/models/orm/task.py`, nuova migrazione,
`src/backend/models/api/`, `src/frontend/flask_app/templates/maker/task_editor.html`,
`templates/measure/task_execute.html`, `templates/measure/task_list.html`.
**Fatto quando:** una ricetta con un task di misura ancora privo di quote si comporta da
task di misura.
---
## 3 · Loop di misura
**Base già presente:** `confirmCycle()`, `startMeasurementTimer()`, `onTimerExpired()`,
`timerDisplay`, `playBuzzer()` in `task_execute.html` (righe ~955-1050). La logica è
corretta; il problema è la persistenza (punto 1) e il fatto che allo scadere non si può
riportare l'operatore sul task giusto.
**Intervento:** spostare la scadenza sul server; alla scadenza, **redirezione al task di
misura** della produzione corrente; con più task di misura far ripartire il timer al
completamento dell'**ultimo**; aggiungere «rimisura» che riapre il ciclo senza chiuderlo.
**Timer bidirezionale:** oggi il contatore **solo decrementa e si ferma a zero**
(`timerRemaining--`, poi `onTimerExpired()`); `timerDisplay` formatta minuti:secondi
dal residuo. Va esteso: sotto zero il valore continua **in negativo** e la
visualizzazione passa a "oltre da m:s", con stile in evidenza. Calcolando dal
timestamp di scadenza lato server (come sopra), il ritardo è coerente su qualunque
schermata e sopravvive al ricaricamento.
**Fatto quando:** con una ricetta a due task di misura e task documentali in coda, allo
scadere del timer l'operatore viene riportato alla misura da qualunque schermata.
---
## 4 · Limite di tentativi
**Problema tecnico:** nessun `max_retries` nel codice.
**Intervento:** campo sulla ricetta (accanto a `measurement_interval_minutes`, che segue
lo stesso schema), contatore per quota nella produzione corrente, superata la soglia
riuso del modale capoturno esistente con motivo `max_retries`.
**File:** `src/backend/models/orm/recipe.py`, migrazione,
`templates/maker/recipe_editor.html` (accanto al timer), `task_execute.html`.
---
## 5 · Avanzamento in tolleranza — prima verificare
Il modale capoturno gestisce già `out_of_tolerance` (`task_execute.html`,
`openSupervisorModal`, `validateSupervisor`, endpoint
`measure.validate_supervisor`). **Prima di sviluppare, provare**: se il rifiuto blocca
l'avanzamento, il punto è chiuso. Se è solo una conferma, va reso vincolante.
---
## 6 · Fermo linea e Fine produzione
**Problema tecnico:** in `task_execute.html` i due pulsanti aprono il modale e poi
ricadono su un commento: `fermo_linea and fine_produzione are handled by GAIA
integration (future)`. `startProduction()` è un `TODO` con la chiamata commentata.
**Intervento (senza gestionale):** scrivere gli eventi su `production_events`,
sospendere e riprendere il timer, chiudere la produzione, ed **emettere il file di
statistica** con tutte le misure della produzione. L'invio al gestionale resta un
adattatore separato da riempire quando il protocollo sarà definito (Parte 3): va
previsto il punto d'innesto, non l'implementazione.
**Fatto quando:** *fermo linea* congela il timer e solo il capoturno lo riattiva; *fine
produzione* chiude e produce il file.
---
## 7 · Stazioni
**Base presente:** `Station` e `StationRecipeAssignment` (`models/orm/station.py`,
migrazione 002).
**Intervento:** ricette collegate nella lista stazioni; pulsante di reset **per riga**
che rimuove le assegnazioni; selezione della stazione corrente da parametro URL con
ricaduta sulla variabile d'ambiente, per il collaudo da una sola macchina.
**Nota (28/07, chiude il dubbio che era in D-3):** in produzione l'identità della
stazione è data dall'**installazione locale** dell'app di stazione, non dal container.
Il cambio al volo via URL resta come strumento di **collaudo**.
---
## 8 · Tracciabilità
**Base presente:** `lot_number` e `serial_number` su `Measurement`, salvataggio in
sessione (`measure.save_traceability`), inserimento in `task_list.html`.
**Intervento:** flag sulla ricetta (`richiede lotto`, `richiede seriale`); spostare
l'inserimento sull'avvio produzione; *Avvia* disabilitato finché mancano.
---
## 9 · Inserimento manuale
**Base presente:** `Measurement.input_method` (`manual | usb_caliper`), valorizzato dal
frontend.
**Intervento:** flag sulla ricetta `consente inserimento manuale`, **predefinito falso**;
validazione **lato server** — un flag solo nel frontend non protegge da nulla; tastierino
nascosto quando vietato.
---
## 10 · Interfaccia operatore
Interventi su `templates/measure/task_list.html` e `task_execute.html`: pulsante di
avvio in sequenza, retrocessione della lista a secondo livello, rinomina dei due
pulsanti, stato «incompiuto» sui task abbandonati, «fine ciclo misura» abilitato solo a
quote complete.
---
## 11 · Formattazione descrizioni
`RecipeTask.description` è già `Text`. Serve un editor minimale (grassetto e a capo) e
la resa corrispondente in esecuzione, con **sanificazione dell'HTML** in ingresso.
**File:** `templates/maker/task_editor.html`, `templates/measure/task_execute.html`.
---
## 12 · Funzionamento senza internet
**Verificato sul codice.** Cinque librerie esterne in sei template:
| Libreria | Dove | Senza rete si perde |
|---|---|---|
| Alpine.js `3.x.x` | `base.html` | **tutta l'interfaccia** |
| Plotly `2.32.0` | `statistics/dashboard.html` | carte di controllo e istogrammi |
| PDF.js `3.11.174` | `task_execute`, `recipe_preview`, `task_drawing`, `task_editor` | visualizzazione dei disegni |
| Fabric.js `5.3.1` | `maker/task_drawing.html` | editor delle annotazioni |
| Google Fonts | `base.html` | estetica e attese al caricamento |
**Intervento:** scaricare le librerie in `src/frontend/flask_app/static/vendor/` (la
cartella **esiste già ed è vuota**), ripuntare i tag, chiudere la policy di sicurezza in
`security_headers.py` da elenco-di-CDN a solo-origine-locale.
⚠️ **Trappola da non mancare:** PDF.js ha una **seconda** referenza al CDN,
`pdfjsLib.GlobalWorkerOptions.workerSrc`, presente in **quattro file**. Ripuntando solo
lo script principale la libreria si carica in locale **e il worker continua a cercare
internet**: sembra funzionare finché non si apre un disegno.
**Fatto quando:** con la rete staccata si percorre login → scelta ricetta → esecuzione
task → annotazione disegno → statistiche → report, senza errori in console.
---
# Parte 3 — Decisioni che non dipendono da noi
Da chiudere **prima** che i punti collegati entrino in sviluppo. Vanno girate a Tràfilo
tramite Menoncin.
| # | Domanda | Blocca | Chi risponde |
|---|---|---|---|
| **D-1** | **Protocollo del gestionale GAIA**: come si scambiano i dati — servizi web, database condiviso, file? | avvio produzione, fermo linea, fine produzione verso il gestionale; lettura dei codici articolo per stazione | IT Tràfilo + fornitore GAIA |
| **D-2** | **Rete e credenziali** per raggiungere GAIA dal server dove sarà installato | come sopra | IT Tràfilo |
| ~~**D-3**~~ | ~~Una applicazione per stazione o una sola per tutte?~~ **Decisa il 28/07** lato Tielogic: backend dati sul server, app di stazione installata su ogni PC — vedi *Architettura d'installazione*. Resta la validazione con l'IT di Tràfilo (macchina e rete) | — | chiusa (noi); validazione in D-4 |
| **D-4** | **Server**: quale macchina, quanto spazio disco. Sul server stanno database **e file dei disegni** (`uploads/`); l'elaborazione visione **non** è sul server (sta sull'app di stazione), quindi pesa lo spazio disco, non la potenza di calcolo | installazione | IT Tràfilo |
| **D-5** | **Cicalino**: basta il suono del browser o serve una segnalazione luminosa? La specifica chiede luce **e** suono accesi per tutta la misura, visibili da lontano. Con l'architettura del 28/07 l'app di stazione **può pilotare una colonnina**: la domanda diventa *quale hardware* | punto 3 | Tràfilo |
| **D-6** | **Autorizzazione capoturno**: username e password come oggi, o PIN rapido / badge? Venti volte al giorno la password è un attrito | punti 4, 5, 6 | Tràfilo |
| **D-7** | **Numero di tentativi** consentiti prima del capoturno: quanti, e uguali per tutte le ricette? | punto 4 | Tràfilo |
| **D-8** | **Schede tecniche**: quante sono, il formato è standard, e serve una conversione una-tantum o una funzione permanente? | punto 13 e la sua quotazione | Tràfilo |
| **D-9** | Modificare i parametri di una ricetta (timer, tentativi) **crea una nuova versione** o no? Sono parametri di esercizio, non di prodotto | punti 4, 3 | noi, con conferma cliente |
**Nota su D-1 e D-2:** finché non hanno risposta, del gestionale si può solo predisporre
il punto d'innesto. Tutto il resto del punto 6 — sospendere, riprendere, chiudere,
registrare, produrre il file — **si fa comunque e va fatto adesso**.
---
## Appendice — Come è stato verificato
Ogni «già fatto» e ogni «da fare» viene dalla lettura del codice al commit `2a56632`,
non dai documenti. In particolare:
- il **tipo di task** è dedotto da `subtasks.length > 0` in `task_execute.html:117` e
`task_list.html:169`;
- il **ruolo capoturno** esiste come `Supervisor`
(`api/middleware/api_key.py:71`, `blueprints/measure.py:350`);
- **fermo linea / fine produzione / avvio produzione** hanno interfaccia e gate ma
nessun effetto (`task_execute.html:1017-1021`, `1118`);
- il **timer** è un `setInterval` locale alla pagina (`task_execute.html:968-995`) e la
navigazione fra task è un ricaricamento (`task_execute.html:1054`);
- **`max_retries`** e **`production_events`** non compaiono in nessun file;
- le **cinque librerie da CDN** sono ai riferimenti citati al punto 12, e
`static/vendor/` contiene solo `.gitkeep`;
- i **disegni stanno su filesystem**, non nel database: cartella `uploads/` servita
dal backend (`api/routers/files.py:201`, `FileResponse`) — verifica del 28/07 sera,
corregge quanto detto in call;
- la **statistica è già riservata**: ogni route di `blueprints/statistics.py` porta
`@role_required("Metrologist")`; a fine ciclo il frontend va al riepilogo
(`task_execute.html:1126`), non alla statistica;
- il **timer si ferma a zero**: decremento in `task_execute.html:974-975` con uscita
su `onTimerExpired()` — il conteggio del ritardo (punto 3) oggi non esiste;
- lo stack attuale è a 4 container (`docker-compose.yml`): MySQL 8, backend FastAPI,
frontend Flask, nginx — base della sezione *Architettura d'installazione*.
Il punto 14 (layout) è l'unico **non verificato sul codice**: è una segnalazione
dell'operatore del 28/07, da riprodurre.
-25
View File
@@ -1,25 +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", "2", "--bind", "0.0.0.0:5000", "app:create_app()"]
-100
View File
@@ -1,100 +0,0 @@
"""TieMeasureFlow Client - Flask Entry Point."""
import json
import os
from flask import Flask, redirect, url_for, session, request
from flask_babel import Babel
from flask_wtf.csrf import CSRFProtect
from markupsafe import Markup
from config import Config
def get_locale():
"""Get user's preferred language from session or Accept-Language header."""
# 1. User preference in session
if "language" in session:
return session["language"]
# 2. Browser Accept-Language
return request.accept_languages.best_match(
Config.LANGUAGES.keys(), default="it"
)
def create_app() -> Flask:
"""Application factory."""
app = Flask(__name__)
app.config.from_object(Config)
# Initialize CSRF protection
csrf = CSRFProtect(app)
# Initialize Flask-Babel
Babel(app, locale_selector=get_locale)
# Register blueprints
from blueprints.auth import auth_bp
from blueprints.measure import measure_bp
from blueprints.maker import maker_bp
from blueprints.statistics import statistics_bp
from blueprints.admin import admin_bp
app.register_blueprint(auth_bp)
app.register_blueprint(measure_bp, url_prefix="/measure")
app.register_blueprint(maker_bp, url_prefix="/maker")
app.register_blueprint(statistics_bp, url_prefix="/statistics")
app.register_blueprint(admin_bp, url_prefix="/admin")
@app.route("/")
def index():
"""Root redirect to login or dashboard based on session."""
if "user" in session:
return redirect(url_for("measure.select_recipe"))
return redirect(url_for("auth.login"))
@app.route("/set-language/<lang>")
def set_language(lang):
"""Set user's preferred language and store in session."""
if lang in Config.LANGUAGES:
session["language"] = lang
return redirect(request.referrer or url_for("auth.login"))
@app.template_filter("tojson_attr")
def tojson_attr_filter(value):
"""JSON encode safe for HTML attributes (x-data, etc.).
Unlike |tojson, this escapes double quotes to &#34; so the output
can be safely embedded inside double-quoted HTML attributes.
The browser decodes the entities before Alpine.js evaluates them.
"""
rv = json.dumps(value, ensure_ascii=False)
rv = (
rv.replace("&", "\\u0026")
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("'", "\\u0027")
.replace('"', "&#34;")
)
return Markup(rv)
@app.context_processor
def inject_globals():
"""Inject global variables into all templates."""
return {
"current_user": session.get("user"),
"current_theme": session.get("theme", "light"),
"current_language": get_locale(),
"languages": Config.LANGUAGES,
"company_logo": session.get("company_logo"),
}
return app
if __name__ == "__main__":
app = create_app()
app.run(
host=os.getenv("CLIENT_HOST", "0.0.0.0"),
port=int(os.getenv("CLIENT_PORT", "5000")),
debug=True,
)
-343
View File
@@ -1,343 +0,0 @@
"""MeasurementTec blueprint - recipe selection and measurement execution."""
import requests as http_requests
from flask import (
Blueprint, Response, flash, jsonify, redirect, render_template,
request, session, url_for,
)
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
from config import Config
from services.api_client import api_client
measure_bp = Blueprint("measure", __name__)
# ---------------------------------------------------------------------------
# Route: Recipe selection
# ---------------------------------------------------------------------------
@measure_bp.route("/select")
@login_required
@role_required("MeasurementTec")
def select_recipe():
"""Recipe selection page with search and barcode support."""
# Fail-fast if STATION_CODE is not configured
if not Config.STATION_CODE:
return render_template("errors/station_not_configured.html"), 503
# Load recipes filtered by station
try:
resp = api_client.get_station_recipes(Config.STATION_CODE)
except Exception as e:
return render_template(
"errors/station_not_configured.html", error=str(e),
), 502
if isinstance(resp, dict) and resp.get("error"):
flash(
_("Errore nel caricamento delle ricette: %(detail)s",
detail=resp.get("detail", "")),
"error",
)
recipes = []
else:
# API may return paginated envelope or plain list
recipes = resp.get("items", resp) if isinstance(resp, dict) else resp
# Auto-fill from query params
auto_recipe_code = request.args.get("recipe", "")
auto_lot = request.args.get("lot", session.get("lot_number", ""))
auto_serial = request.args.get("serial", session.get("serial_number", ""))
return render_template(
"measure/select_recipe.html",
recipes=recipes,
station_code=Config.STATION_CODE,
auto_recipe_code=auto_recipe_code,
auto_lot=auto_lot,
auto_serial=auto_serial,
)
# ---------------------------------------------------------------------------
# Route: Task list for a recipe
# ---------------------------------------------------------------------------
@measure_bp.route("/tasks/<int:recipe_id>")
@login_required
@role_required("MeasurementTec")
def task_list(recipe_id: int):
"""Task list for selected recipe."""
# Persist lot/serial from query params into session
lot_number = request.args.get(
"lot_number", session.get("lot_number", ""),
)
serial_number = request.args.get(
"serial_number", session.get("serial_number", ""),
)
if lot_number:
session["lot_number"] = lot_number
if serial_number:
session["serial_number"] = serial_number
# Load recipe details
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
if recipe_resp.get("error"):
flash(
_("Ricetta non trovata: %(detail)s",
detail=recipe_resp.get("detail", "")),
"error",
)
return redirect(url_for("measure.select_recipe"))
# Load tasks for this recipe
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, dict) and tasks_resp.get("error"):
flash(
_("Errore nel caricamento dei task: %(detail)s",
detail=tasks_resp.get("detail", "")),
"error",
)
tasks = []
else:
tasks = tasks_resp if isinstance(tasks_resp, list) else tasks_resp.get("items", [])
return render_template(
"measure/task_list.html",
recipe=recipe_resp,
tasks=tasks,
lot_number=lot_number,
serial_number=serial_number,
)
# ---------------------------------------------------------------------------
# Route: Task execution (measurement input)
# ---------------------------------------------------------------------------
@measure_bp.route("/execute/<int:task_id>")
@login_required
@role_required("MeasurementTec")
def task_execute(task_id: int):
"""Execute measurements for a task."""
# Load task + subtasks
task_resp = api_client.get(f"/api/tasks/{task_id}")
if task_resp.get("error"):
flash(
_("Task non trovato: %(detail)s",
detail=task_resp.get("detail", "")),
"error",
)
return redirect(url_for("measure.select_recipe"))
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
# Load all task IDs for this recipe (ordered) for auto-advance
recipe_id = task_resp.get("recipe_id")
all_task_ids = []
if recipe_id:
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, list):
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
all_task_ids = [t["id"] for t in sorted_tasks]
return render_template(
"measure/task_execute.html",
task=task_resp,
lot_number=lot_number,
serial_number=serial_number,
all_task_ids=all_task_ids,
)
# ---------------------------------------------------------------------------
# Route: Task completion summary
# ---------------------------------------------------------------------------
@measure_bp.route("/complete/<int:recipe_id>")
@login_required
@role_required("MeasurementTec")
def task_complete(recipe_id: int):
"""Task completion summary with all measurements."""
# Retrieve version_id from query params
version_id = request.args.get("version_id")
# Load recipe for context
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
if recipe_resp.get("error"):
flash(
_("Ricetta non trovata: %(detail)s",
detail=recipe_resp.get("detail", "")),
"error",
)
return redirect(url_for("measure.select_recipe"))
# Load tasks+subtasks for this recipe to build subtask and task lookup
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
subtask_map = {}
subtask_task_map = {} # subtask_id → task info
if isinstance(tasks_resp, list):
for task in tasks_resp:
for st in task.get("subtasks", []):
subtask_map[st["id"]] = st
subtask_task_map[st["id"]] = {
"id": task["id"],
"title": task.get("title", ""),
"order_index": task.get("order_index", 0),
}
# Load measurements if version_id provided
measurements = []
if version_id:
meas_resp = api_client.get(
"/api/measurements",
params={"version_id": version_id, "per_page": 500},
)
if not (isinstance(meas_resp, dict) and meas_resp.get("error")):
raw = (
meas_resp if isinstance(meas_resp, list)
else meas_resp.get("items", [])
)
# Enrich each measurement with nested subtask data
for m in raw:
st = subtask_map.get(m.get("subtask_id"), {})
m["subtask"] = st
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
# Compute deviation if not present
if m.get("deviation") is None and st.get("nominal") is not None:
try:
m["deviation"] = m["value"] - st["nominal"]
except (TypeError, KeyError):
m["deviation"] = 0.0
measurements = raw
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
return render_template(
"measure/task_complete.html",
recipe=recipe_resp,
measurements=measurements,
lot_number=lot_number,
serial_number=serial_number,
)
# ---------------------------------------------------------------------------
# Route: Barcode lookup (AJAX)
# ---------------------------------------------------------------------------
@measure_bp.route("/lookup-barcode", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def lookup_barcode():
"""Look up a recipe by barcode/code. Returns JSON for AJAX calls."""
data = request.get_json(silent=True) or {}
code = data.get("code", "").strip()
if not code:
return jsonify({"error": True, "detail": _("Codice non fornito")}), 400
resp = api_client.get(f"/api/recipes/code/{code}")
if resp.get("error"):
return jsonify({
"error": True,
"detail": resp.get("detail", _("Ricetta non trovata")),
}), 404
return jsonify(resp)
# ---------------------------------------------------------------------------
# Route: Save lot/serial to session (AJAX)
# ---------------------------------------------------------------------------
@measure_bp.route("/save-traceability", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def save_traceability():
"""Save lot_number and serial_number to session."""
data = request.get_json(silent=True) or {}
lot = data.get("lot_number", "").strip()
serial = data.get("serial_number", "").strip()
if lot:
session["lot_number"] = lot
if serial:
session["serial_number"] = serial
return jsonify({"ok": True})
# ---------------------------------------------------------------------------
# Route: Save measurement (AJAX proxy to FastAPI)
# ---------------------------------------------------------------------------
@measure_bp.route("/save-measurement", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def save_measurement():
"""Save a single measurement value via API proxy.
Expects JSON body:
subtask_id: int
task_id: int
value: float
pass_fail: str ('pass' | 'warning' | 'fail')
deviation: float
lot_number: str (optional)
serial_number: str (optional)
Returns JSON with the created measurement or error.
"""
data = request.get_json(silent=True) or {}
# Validate required fields
subtask_id = data.get("subtask_id")
version_id = data.get("version_id")
value = data.get("value")
if subtask_id is None or version_id is None or value is None:
return jsonify({
"error": True,
"detail": _("Dati mancanti: subtask_id, version_id e value sono obbligatori"),
}), 400
# Build payload for the FastAPI backend
payload = {
"subtask_id": subtask_id,
"version_id": version_id,
"value": value,
"lot_number": data.get("lot_number", session.get("lot_number", "")),
"serial_number": data.get("serial_number", session.get("serial_number", "")),
"input_method": data.get("input_method", "manual"),
}
resp = api_client.post("/api/measurements", data=payload)
if resp.get("error"):
status_code = resp.get("status_code", 500)
return jsonify({
"error": True,
"detail": resp.get("detail", _("Errore nel salvataggio")),
}), status_code if status_code >= 400 else 500
return jsonify(resp), 201
# ---------------------------------------------------------------------------
# Route: File proxy (browser can't send X-API-Key directly)
# ---------------------------------------------------------------------------
@measure_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required
def api_get_file(file_path: str):
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
api_key = session.get("api_key", "")
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/files/{file_path}",
headers={"X-API-Key": api_key},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
-4
View File
@@ -1,4 +0,0 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
-15
View File
@@ -1,15 +0,0 @@
# Flask
flask>=3.0.0
flask-babel>=4.0.0
flask-wtf>=1.2.0
# HTTP Client (to call FastAPI server)
requests>=2.31.0
urllib3>=2.0.0
# Utilities
python-dotenv>=1.0.0
# Testing
pytest>=8.0.0
coverage>=7.0.0
-27
View File
@@ -1,27 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<!-- Stylized Caliper Icon for Favicon -->
<!-- Caliper outer frame -->
<rect x="2" y="3" width="5" height="26" rx="1.5" fill="#2563EB"/>
<rect x="2" y="3" width="19" height="5" rx="1.5" fill="#2563EB"/>
<rect x="2" y="24" width="19" height="5" rx="1.5" fill="#2563EB"/>
<!-- Sliding jaw -->
<rect x="12" y="8" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
<rect x="12" y="17.5" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
<!-- Depth rod -->
<rect x="4" y="14" width="12" height="3.5" rx="1" fill="#3B82F6" opacity="0.55"/>
<!-- Scale markings -->
<rect x="8" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="11" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="14" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="17" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<!-- Flow arrow -->
<path d="M22 16 C25 16, 26.5 11, 29 11"
stroke="#3B82F6" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M27 8.5 L29.5 11 L27 13.5"
stroke="#3B82F6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-41
View File
@@ -1,41 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 44" fill="none">
<!-- Stylized Caliper Icon -->
<g transform="translate(2, 2)">
<!-- Caliper outer frame -->
<rect x="0" y="4" width="6" height="32" rx="1.5" fill="#2563EB"/>
<rect x="0" y="4" width="24" height="6" rx="1.5" fill="#2563EB"/>
<rect x="0" y="30" width="24" height="6" rx="1.5" fill="#2563EB"/>
<!-- Caliper sliding jaw -->
<rect x="14" y="10" width="5" height="8" rx="1" fill="#1E40AF"/>
<rect x="14" y="22" width="5" height="8" rx="1" fill="#1E40AF"/>
<!-- Caliper depth rod -->
<rect x="2" y="18" width="16" height="4" rx="1" fill="#3B82F6" opacity="0.6"/>
<!-- Scale markings -->
<rect x="7" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="10" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="13" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="16" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="19" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<!-- Flow arrow (smooth curve) -->
<path d="M26 20 C30 20, 32 14, 36 14 C40 14, 40 20, 36 20"
stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M34 17 L37 20 L34 23" stroke="#2563EB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</g>
<!-- Text: TieMeasureFlow -->
<g transform="translate(48, 0)">
<!-- "Tie" in bold primary -->
<text x="0" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
letter-spacing="-0.5">Tie</text>
<!-- "Measure" in medium dark -->
<text x="32" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="500" fill="#1E40AF"
letter-spacing="-0.5">Measure</text>
<!-- "Flow" in bold primary -->
<text x="120" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
letter-spacing="-0.5">Flow</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

-95
View File
@@ -1,95 +0,0 @@
{% extends "base.html" %}
{% block title %}Login — TieMeasureFlow{% endblock %}
{% block content %}
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<!-- Card -->
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-xl p-8">
<!-- Logo -->
<div class="text-center mb-8">
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
alt="TieMeasureFlow Logo"
class="mx-auto h-16 w-auto mb-4"
onerror="this.style.display='none'">
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-2">
TieMeasureFlow
</h2>
<p class="text-sm text-slate-600 dark:text-slate-400">
{{ _('Accedi al sistema') }}
</p>
</div>
<!-- Login Form -->
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Username -->
<div>
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Username') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input type="text"
id="username"
name="username"
required
autofocus
placeholder="{{ _('Username') }}"
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
</div>
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Password') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input type="password"
id="password"
name="password"
required
placeholder="{{ _('Password') }}"
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
</div>
</div>
<!-- Submit Button -->
<div>
<button type="submit"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
{{ _('Accedi') }}
</button>
</div>
</form>
<!-- Help Text -->
<div class="mt-6 text-center">
<p class="text-xs text-slate-500 dark:text-slate-400">
{{ _('Hai dimenticato la password?') }}
<br>
{{ _('Contatta l\'amministratore') }}
</p>
</div>
</div>
<!-- Footer -->
<div class="text-center text-xs text-slate-500 dark:text-slate-400">
<p>TieMeasureFlow &copy; 2025 - {{ _('Sistema di misurazione industriale') }}</p>
</div>
</div>
</div>
{% endblock %}
-307
View File
@@ -1,307 +0,0 @@
<!-- TieMeasureFlow Navbar -->
<nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300"
x-data="{ mobileOpen: false, userDropdown: false }">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
<!-- Left: Logo -->
<div class="flex items-center gap-3">
<a href="{{ url_for('index') }}" class="flex items-center gap-2.5 group">
{% if company_logo %}
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-8 w-auto"
onerror="this.src='{{ url_for('static', filename='img/tmflow-logo.svg') }}'">
{% else %}
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
alt="TieMeasureFlow"
class="h-8 w-auto">
{% endif %}
</a>
</div>
<!-- Center: Navigation Links (Desktop) -->
{% if current_user %}
<div class="hidden md:flex items-center gap-1">
{# MeasurementTec: Misure #}
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
<a href="{{ url_for('measure.select_recipe') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('measure.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Clipboard Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
<span>{{ _('Misure') }}</span>
</a>
{% endif %}
{# Maker: Ricette #}
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
<a href="{{ url_for('maker.recipe_list') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('maker.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Edit/Pencil Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
<span>{{ _('Ricette') }}</span>
</a>
{% endif %}
{# Metrologist: Statistiche #}
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
<a href="{{ url_for('statistics.dashboard') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('statistics.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Chart Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<span>{{ _('Statistiche') }}</span>
</a>
{% endif %}
{# Admin: Utenti #}
{% if current_user.get('is_admin') %}
<a href="{{ url_for('admin.user_list') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint == 'admin.user_list' %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Users Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
<span>{{ _('Utenti') }}</span>
</a>
<a href="{{ url_for('admin.station_list') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint == 'admin.station_list' %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Station / Workstation Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
</svg>
<span>{{ _('Stazioni') }}</span>
</a>
{% endif %}
</div>
{% endif %}
<!-- Right: Controls -->
<div class="flex items-center gap-2">
<!-- Language Toggle -->
<div class="flex items-center border border-[var(--border-color)] rounded-lg overflow-hidden">
<a href="{{ url_for('set_language', lang='it') }}"
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
{% if current_language == 'it' %}
bg-primary text-white
{% else %}
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
{% endif %}">
IT
</a>
<a href="{{ url_for('set_language', lang='en') }}"
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
{% if current_language == 'en' %}
bg-primary text-white
{% else %}
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
{% endif %}">
EN
</a>
</div>
<!-- Theme Toggle -->
<button @click="$store.theme.toggle()"
class="p-2 rounded-lg text-[var(--text-secondary)] hover:text-primary
hover:bg-[var(--bg-secondary)] transition-colors duration-200"
:title="$store.theme.dark ? 'Tema chiaro' : 'Tema scuro'">
<!-- Sun (shown in dark mode) -->
<svg x-show="$store.theme.dark" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<!-- Moon (shown in light mode) -->
<svg x-show="!$store.theme.dark" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
</button>
<!-- User Menu (when logged in) -->
{% if current_user %}
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open"
class="flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-lg
hover:bg-[var(--bg-secondary)] transition-colors duration-200">
<!-- Avatar -->
<div class="w-7 h-7 rounded-full bg-primary/10 border border-primary/20
flex items-center justify-center text-primary font-semibold text-xs">
{{ current_user.get('display_name', current_user.get('username', '?'))[0]|upper }}
</div>
<span class="hidden sm:block text-sm font-medium text-[var(--text-primary)] max-w-[120px] truncate">
{{ current_user.get('display_name', current_user.get('username', '')) }}
</span>
<svg class="w-3.5 h-3.5 text-[var(--text-secondary)] transition-transform duration-200"
:class="{ 'rotate-180': open }"
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<!-- User Dropdown -->
<div x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95 -translate-y-1"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-1 w-52 rounded-lg bg-[var(--bg-card)] border border-[var(--border-color)]
shadow-lg py-1 z-50">
<!-- User Info Header -->
<div class="px-4 py-2.5 border-b border-[var(--border-color)]">
<p class="text-sm font-semibold text-[var(--text-primary)] truncate">
{{ current_user.get('display_name', current_user.get('username', '')) }}
</p>
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
{{ current_user.get('roles', [])|join(', ') }}
</p>
</div>
<a href="{{ url_for('auth.profile') }}"
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-secondary)]
hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
{{ _('Profilo') }}
</a>
<div class="border-t border-[var(--border-color)] my-1"></div>
<a href="{{ url_for('auth.logout') }}"
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail
hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
{{ _('Logout') }}
</a>
</div>
</div>
{% endif %}
<!-- Mobile Hamburger (when logged in) -->
{% if current_user %}
<button @click="mobileOpen = !mobileOpen"
class="md:hidden p-2 rounded-lg text-[var(--text-secondary)]
hover:bg-[var(--bg-secondary)] transition-colors duration-200">
<svg x-show="!mobileOpen" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
<svg x-show="mobileOpen" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
{% endif %}
</div>
</div>
</div>
<!-- Mobile Menu -->
{% if current_user %}
<div x-show="mobileOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
x-cloak
class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]">
<div class="px-4 py-3 space-y-1">
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
<a href="{{ url_for('measure.select_recipe') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
{{ _('Misure') }}
</a>
{% endif %}
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
<a href="{{ url_for('maker.recipe_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
{{ _('Ricette') }}
</a>
{% endif %}
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
<a href="{{ url_for('statistics.dashboard') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
{{ _('Statistiche') }}
</a>
{% endif %}
{# Admin: Utenti + Stazioni #}
{% if current_user.get('is_admin') %}
<a href="{{ url_for('admin.user_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
{{ _('Utenti') }}
</a>
<a href="{{ url_for('admin.station_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
</svg>
{{ _('Stazioni') }}
</a>
{% endif %}
</div>
</div>
{% endif %}
</nav>
-708
View File
@@ -1,708 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
{% block extra_head %}
<style>
/* Annotation viewer container */
.annotation-container {
position: relative;
width: 100%;
height: 100%;
background: var(--bg-secondary);
overflow: hidden;
}
.annotation-container canvas {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}
/* Tolerance bar gradient background */
.tolerance-bar-bg {
background: linear-gradient(to right,
rgba(220, 38, 38, 0.15) 0%,
rgba(217, 119, 6, 0.15) 15%,
rgba(5, 150, 105, 0.15) 30%,
rgba(5, 150, 105, 0.15) 70%,
rgba(217, 119, 6, 0.15) 85%,
rgba(220, 38, 38, 0.15) 100%
);
}
/* Pulse animation for active marker */
@keyframes markerPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(37, 99, 235, 0); }
}
.marker-active-pulse {
animation: markerPulse 2s ease-in-out infinite;
}
/* Left sidebar scrollbar */
.sidebar-markers::-webkit-scrollbar { width: 3px; }
.sidebar-markers::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
/* Right panel scrollbar */
.right-panel::-webkit-scrollbar { width: 4px; }
.right-panel::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
</style>
{% endblock %}
{% block content %}
<script>
window.__taskData = {{ task|tojson }};
window.__taskSubtasks = {{ task.subtasks|tojson if task.subtasks else '[]' }};
window.__taskAnnotations = {{ task.annotations_json|default('null')|tojson }};
window.__allTaskIds = {{ all_task_ids|tojson }};
</script>
<div class="h-screen flex flex-col overflow-hidden"
x-data="taskExecute()"
x-init="init()"
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
{# ================================================================
HEADER — Compact task info bar
================================================================ #}
<div class="shrink-0 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm z-20">
<div class="px-3 py-2 flex items-center gap-3">
{# Back button #}
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
class="shrink-0 p-1.5 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-muted)] hover:text-[var(--text-primary)]">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
{# Task badge + title #}
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
border border-primary-200 dark:border-primary-800">
Task {{ (task.order_index or 0) + 1 }}
</span>
<h1 class="text-sm font-bold text-[var(--text-primary)] truncate">
{{ task.title or _('Task di misurazione') }}
</h1>
</div>
{# Lot + Serial badges #}
<div class="shrink-0 flex items-center gap-2">
{% if lot_number %}
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
text-amber-800 dark:text-amber-200">
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<span class="font-mono font-semibold">{{ lot_number }}</span>
</div>
{% endif %}
{% if serial_number %}
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800
text-indigo-800 dark:text-indigo-200">
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
<span class="font-mono font-semibold">{{ serial_number }}</span>
</div>
{% endif %}
{# Caliper status #}
{% include "components/caliper_status.html" %}
</div>
</div>
</div>
{# ================================================================
MAIN — 3-column layout
================================================================ #}
<div class="flex-1 flex overflow-hidden">
{# ──────────────────────────────────────────────
LEFT SIDEBAR — Marker list (vertical)
────────────────────────────────────────────── #}
<div class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
<template x-for="(st, idx) in subtasks" :key="st.id">
<button @click="goToSubtask(idx)"
class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
:class="idx === currentIndex
? 'bg-primary-50 dark:bg-primary-900/20 border-l-[3px] border-l-primary'
: 'hover:bg-[var(--bg-secondary)] border-l-[3px] border-l-transparent'">
{# Marker circle #}
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-all"
:class="idx === currentIndex
? 'bg-primary text-white shadow-md marker-active-pulse'
: getMeasurementStatus(st.id) === 'pass'
? 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40'
: getMeasurementStatus(st.id) === 'fail'
? 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40'
: getMeasurementStatus(st.id) === 'warning'
? 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40'
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]'"
x-text="st.marker_number"></span>
{# Status icon below circle #}
<template x-if="getMeasurementStatus(st.id) === 'pass'">
<svg class="w-3 h-3 mt-0.5 text-measure-pass" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
</template>
<template x-if="getMeasurementStatus(st.id) === 'fail'">
<svg class="w-3 h-3 mt-0.5 text-measure-fail" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</template>
<template x-if="getMeasurementStatus(st.id) === 'warning'">
<svg class="w-3 h-3 mt-0.5 text-measure-warning" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
</template>
</button>
</template>
</div>
{# ──────────────────────────────────────────────
CENTER — Image area
────────────────────────────────────────────── #}
<div class="flex-1 flex flex-col overflow-hidden bg-[var(--bg-secondary)]">
{# Main image area #}
<div class="flex-1 overflow-hidden relative">
{# Option A: Subtask has its own image → plain <img> #}
<div x-show="showSubtaskImage" class="absolute inset-0 flex items-center justify-center p-2">
<img :src="'/measure/api/files/' + currentSubtaskImage"
alt="{{ _('Immagine dettaglio misura') }}"
class="max-w-full max-h-full object-contain rounded-lg shadow-sm">
</div>
{# Option B: Task image with annotations → annotation-viewer (kept in DOM via x-show) #}
<div x-show="showTaskImage" class="absolute inset-0">
{% if task.file_path %}
<div class="annotation-container"
x-data="annotationViewer()"
x-init="
imageUrl = '/measure/api/files/{{ task.file_path }}';
annotations = window.__taskAnnotations;
$nextTick(() => init());
"
x-effect="setActiveMarker(currentSubtask?.marker_number || 0)">
<canvas x-ref="annotationCanvas"
@click="handleClick($event)"
class="cursor-pointer"></canvas>
</div>
{% endif %}
</div>
{# Option C: No image → placeholder #}
<div x-show="!showSubtaskImage && !showTaskImage" class="absolute inset-0 flex items-center justify-center">
<div class="text-center">
<svg class="w-16 h-16 mx-auto text-[var(--text-muted)] mb-3 opacity-30" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm font-medium text-[var(--text-muted)]">{{ _('Nessuna immagine allegata') }}</p>
</div>
</div>
</div>
</div>
{# ──────────────────────────────────────────────
RIGHT PANEL — Info + tolerances + numpad
────────────────────────────────────────────── #}
<div class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
{# ---- Subtask header ---- #}
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full
bg-primary text-white font-bold text-sm shadow marker-active-pulse"
x-text="currentSubtask?.marker_number"></span>
<div class="min-w-0 flex-1">
<h2 class="font-semibold text-[var(--text-primary)] text-sm leading-tight truncate"
x-text="currentSubtask?.description || '{{ _('Misurazione') }}'"></h2>
<span class="text-[11px] text-[var(--text-muted)]"
x-text="(currentSubtask?.measurement_type || '{{ _('Misura') }}') + ' (' + (currentSubtask?.unit || 'mm') + ')'"></span>
</div>
<span class="shrink-0 text-[11px] font-mono text-[var(--text-muted)] bg-[var(--bg-secondary)] px-1.5 py-0.5 rounded">
<span x-text="currentIndex + 1"></span>/<span x-text="totalSubtasks"></span>
</span>
</div>
</div>
{# ---- Tolerance parameters (compact grid) ---- #}
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
{# Nominal row #}
<div class="flex items-center justify-between py-1 px-2 rounded bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-800 mb-1.5">
<span class="text-[10px] font-medium text-primary-700 dark:text-primary-300 uppercase tracking-wider">{{ _('Nominale') }}</span>
<span class="font-mono font-bold text-primary text-sm"
x-text="currentSubtask?.nominal?.toFixed(3) || '—'"></span>
</div>
{# 2x2 tolerance grid #}
<div class="grid grid-cols-2 gap-1 text-[11px]">
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
<span class="text-measure-fail font-medium">UTL</span>
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.utl?.toFixed(3) || '—'"></span>
</div>
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
<span class="text-measure-fail font-medium">LTL</span>
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.ltl?.toFixed(3) || '—'"></span>
</div>
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
<span class="text-measure-warning font-medium">UWL</span>
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.uwl?.toFixed(3) || '—'"></span>
</div>
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
<span class="text-measure-warning font-medium">LWL</span>
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.lwl?.toFixed(3) || '—'"></span>
</div>
</div>
</div>
{# ---- Tolerance bar (compact) ---- #}
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
<div class="tolerance-bar-bg h-5 rounded-full relative overflow-hidden border border-[var(--border-color)]">
{# Zone labels #}
<div class="absolute inset-0 flex items-center justify-between px-1.5 text-[8px] font-mono text-[var(--text-muted)]">
<span x-text="currentSubtask?.ltl?.toFixed(2)"></span>
<span x-text="currentSubtask?.nominal?.toFixed(2)"></span>
<span x-text="currentSubtask?.utl?.toFixed(2)"></span>
</div>
{# Center line #}
<div class="absolute top-0 bottom-0 w-px bg-[var(--text-muted)]/30" style="left: 50%;"></div>
{# Value needle #}
<div x-show="currentValue !== null"
x-transition
class="absolute top-0 bottom-0 w-1 rounded-full transition-all duration-300"
:class="{
'bg-measure-pass': passFailStatus === 'pass',
'bg-measure-warning': passFailStatus === 'warning',
'bg-measure-fail': passFailStatus === 'fail'
}"
:style="'left: calc(' + progressWidth + '% - 2px)'">
<div class="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-800 shadow"
:class="{
'bg-measure-pass': passFailStatus === 'pass',
'bg-measure-warning': passFailStatus === 'warning',
'bg-measure-fail': passFailStatus === 'fail'
}"></div>
</div>
</div>
</div>
{# ---- Already measured indicator ---- #}
<div x-show="isMeasured(currentSubtask?.id)"
class="mx-3 mt-2 p-2 rounded-lg bg-measure-pass/10 border border-measure-pass/20">
<div class="flex items-center gap-1.5 text-xs text-measure-pass font-medium">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<span>{{ _('Registrata') }}:</span>
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
<span x-text="currentSubtask?.unit || 'mm'"></span>
</div>
</div>
{# ---- Measurement feedback ---- #}
<div class="px-3 pt-2" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
get utl() { return currentSubtask?.utl || 0; },
get uwl() { return currentSubtask?.uwl || 0; },
get lwl() { return currentSubtask?.lwl || 0; },
get ltl() { return currentSubtask?.ltl || 0; },
get unit() { return currentSubtask?.unit || 'mm'; } }">
{% include "components/measurement_feedback.html" %}
</div>
{# ---- Numpad ---- #}
<div class="px-3 py-2 relative flex-1">
{# Saving overlay #}
<div x-show="saving"
x-transition
class="absolute inset-0 z-10 bg-white/70 dark:bg-slate-900/70 flex items-center justify-center backdrop-blur-sm rounded">
<div class="flex items-center gap-2 text-primary font-medium text-sm">
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ _('Salvataggio...') }}
</div>
</div>
{% include "components/numpad.html" %}
</div>
{# ---- Next measurement indicator ---- #}
<div class="px-3 pb-2">
{% include "components/next_measurement.html" %}
</div>
{# ---- Error message ---- #}
<div x-show="errorMessage"
x-transition
x-cloak
class="mx-3 mb-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-xs text-measure-fail">
<div class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span x-text="errorMessage"></span>
</div>
</div>
</div>
</div>
{# ================================================================
FOOTER — Progress bar + navigation
================================================================ #}
<div class="shrink-0 bg-[var(--bg-card)] border-t border-[var(--border-color)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)] z-20">
<div class="px-3 py-2 flex items-center gap-3">
{# Left: Back #}
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
class="btn btn-secondary text-xs shrink-0 gap-1 py-1.5 px-2.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
<span class="hidden sm:inline">{{ _('Task') }}</span>
</a>
{# Center: Progress bar #}
<div class="flex-1 flex items-center gap-2">
<span class="text-xs font-medium text-[var(--text-secondary)] shrink-0 font-mono">
<span x-text="completedCount"></span>/<span x-text="totalSubtasks"></span>
</span>
<div class="flex-1 h-2 rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] overflow-hidden">
<div class="h-full rounded-full transition-all duration-500 ease-out"
:class="isComplete ? 'bg-measure-pass' : 'bg-primary'"
:style="'width: ' + progressPercent + '%'"></div>
</div>
<span class="text-xs font-bold shrink-0"
:class="isComplete ? 'text-measure-pass' : 'text-primary'"
x-text="Math.round(progressPercent) + '%'"></span>
</div>
{# Right: Summary button #}
<button x-show="isComplete"
x-transition
@click="goToSummary()"
class="btn btn-primary text-xs shrink-0 gap-1 py-1.5 px-2.5 shadow-md">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Riepilogo') }}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
</div>
</div>
{# ================================================================
COMPLETION OVERLAY
================================================================ #}
<div x-show="showCompletionOverlay"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center"
x-transition:enter="transition ease-out duration-300 delay-100"
x-transition:enter-start="opacity-0 scale-90"
x-transition:enter-end="opacity-100 scale-100">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-measure-pass/10 mb-4">
<svg class="w-8 h-8 text-measure-pass" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">{{ _('Misurazioni Complete') }}</h3>
<p class="text-sm text-[var(--text-secondary)] mb-2">
{{ _('Tutte le') }} <span class="font-bold font-mono" x-text="totalSubtasks"></span> {{ _('misurazioni sono state registrate.') }}
</p>
<div class="flex justify-center gap-4 mb-5">
<div class="text-center">
<div class="text-xl font-bold font-mono text-measure-pass" x-text="passCount"></div>
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Conformi') }}</div>
</div>
<div class="text-center" x-show="warningCount > 0">
<div class="text-xl font-bold font-mono text-measure-warning" x-text="warningCount"></div>
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Attenzione') }}</div>
</div>
<div class="text-center" x-show="failCount > 0">
<div class="text-xl font-bold font-mono text-measure-fail" x-text="failCount"></div>
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Non Conf.') }}</div>
</div>
</div>
<button @click="goToSummary()"
class="btn btn-primary w-full justify-center gap-2">
{{ _('Vai al Riepilogo') }}
<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="M14 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script>
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
<script>
/**
* Task Execute - Main Alpine.js component
* Manages measurement workflow state, save logic, and navigation.
* Layout: 3-column (marker sidebar | image center | info+numpad right)
*/
function taskExecute() {
return {
// ---- Data from server ----
task: window.__taskData,
subtasks: window.__taskSubtasks,
lotNumber: '{{ lot_number or '' }}',
serialNumber: '{{ serial_number or '' }}',
// ---- Measurement state ----
currentIndex: 0,
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
saving: false,
errorMessage: '',
showCompletionOverlay: false,
// ---- Value from numpad / caliper ----
currentValue: null,
// ---- Image switching logic ----
get currentSubtaskImage() {
return this.currentSubtask?.image_path || null;
},
get showSubtaskImage() {
return !!this.currentSubtaskImage;
},
get showTaskImage() {
return !this.currentSubtaskImage && !!this.task.file_path;
},
// ---- Computed properties ----
get currentSubtask() {
return this.subtasks[this.currentIndex] || null;
},
get nextSubtask() {
return this.subtasks[this.currentIndex + 1] || null;
},
get totalSubtasks() {
return this.subtasks.length;
},
get completedCount() {
return this.measurements.length;
},
get isComplete() {
return this.completedCount >= this.totalSubtasks;
},
get progressPercent() {
return this.totalSubtasks > 0
? (this.completedCount / this.totalSubtasks * 100)
: 0;
},
// ---- Pass/fail logic ----
get passFailStatus() {
if (this.currentValue === null || !this.currentSubtask) return null;
const v = this.currentValue;
const s = this.currentSubtask;
if (s.utl != null && v > s.utl) return 'fail';
if (s.ltl != null && v < s.ltl) return 'fail';
if (s.uwl != null && v > s.uwl) return 'warning';
if (s.lwl != null && v < s.lwl) return 'warning';
return 'pass';
},
get deviation() {
if (this.currentValue === null || !this.currentSubtask) return null;
return this.currentValue - (this.currentSubtask.nominal || 0);
},
get progressWidth() {
if (!this.currentSubtask || this.currentValue === null) return 50;
const s = this.currentSubtask;
if (s.utl == null || s.ltl == null) return 50;
const range = s.utl - s.ltl;
if (range <= 0) return 50;
const pos = (this.currentValue - s.ltl) / range * 100;
return Math.max(0, Math.min(100, pos));
},
// ---- Summary counts ----
get passCount() {
return this.measurements.filter(m => m.pass_fail === 'pass').length;
},
get warningCount() {
return this.measurements.filter(m => m.pass_fail === 'warning').length;
},
get failCount() {
return this.measurements.filter(m => m.pass_fail === 'fail').length;
},
// ---- Init ----
init() {
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
},
// ---- Check if a subtask has been measured ----
isMeasured(subtaskId) {
return this.measurements.some(m => m.subtask_id === subtaskId);
},
getMeasuredValue(subtaskId) {
const m = this.measurements.find(m => m.subtask_id === subtaskId);
return m ? m.value : null;
},
getMeasurementStatus(subtaskId) {
const m = this.measurements.find(m => m.subtask_id === subtaskId);
return m ? m.pass_fail : null;
},
// ---- Handle numpad confirm ----
async handleMeasurement(value, inputMethod) {
if (!this.currentSubtask || this.saving) return;
this.currentValue = value;
this.errorMessage = '';
const pf = this.passFailStatus;
const dev = this.deviation;
this.saving = true;
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
const response = await fetch('{{ url_for("measure.save_measurement") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
subtask_id: this.currentSubtask.id,
version_id: this.task.version_id,
value: value,
lot_number: this.lotNumber,
serial_number: this.serialNumber,
input_method: inputMethod || 'manual',
}),
});
const result = await response.json();
if (!response.ok || result.error) {
this.errorMessage = result.detail || '{{ _("Errore nel salvataggio della misurazione") }}';
this.saving = false;
return;
}
// Record measurement locally
const existingIdx = this.measurements.findIndex(m => m.subtask_id === this.currentSubtask.id);
const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: pf, deviation: dev };
if (existingIdx !== -1) {
this.measurements.splice(existingIdx, 1, mEntry);
} else {
this.measurements.push(mEntry);
}
this.saving = false;
// Pause to show result feedback
await new Promise(r => setTimeout(r, 1000));
// Check if all done
if (this.completedCount >= this.totalSubtasks) {
const taskIds = window.__allTaskIds || [];
const currentIdx = taskIds.indexOf(this.task.id);
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
} else {
this.showCompletionOverlay = true;
}
return;
}
this.advanceToNext();
} catch (err) {
console.error('Save measurement error:', err);
this.errorMessage = '{{ _("Errore di rete. Riprovare.") }}';
this.saving = false;
}
},
// ---- Advance to next unmeasured subtask ----
advanceToNext() {
this.currentValue = null;
for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) {
if (!this.isMeasured(this.subtasks[i].id)) {
this.currentIndex = i;
return;
}
}
for (let i = 0; i < this.currentIndex; i++) {
if (!this.isMeasured(this.subtasks[i].id)) {
this.currentIndex = i;
return;
}
}
},
// ---- Navigation ----
goToSubtask(index) {
if (index >= 0 && index < this.totalSubtasks) {
this.currentIndex = index;
this.currentValue = null;
this.errorMessage = '';
}
},
goToSubtaskByMarker(markerNumber) {
const idx = this.subtasks.findIndex(s => s.marker_number === markerNumber);
if (idx !== -1) {
this.goToSubtask(idx);
}
},
// ---- Go to summary ----
goToSummary() {
const recipeId = this.task.recipe_id || 0;
window.location.href = '{{ url_for("measure.task_complete", recipe_id=0) }}'.replace('/0', '/' + recipeId) +
'?version_id=' + encodeURIComponent(this.task.version_id || '');
},
};
}
</script>
{% endblock %}
-247
View File
@@ -1,247 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ recipe.name }} — {{ _('Task') }} — TieMeasureFlow{% endblock %}
{% block content %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl">
<!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<li>
<a href="{{ url_for('measure.select_recipe') }}"
class="hover:text-primary transition-colors inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
{{ _('Misure') }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="font-medium text-[var(--text-primary)]">
{{ recipe.name }}
</li>
</ol>
</nav>
<!-- Recipe Info Card -->
<div class="tmf-card mb-8">
<div class="p-5 sm:p-6">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<!-- Left: Recipe Details -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<!-- Code Badge -->
<span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-semibold
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
border border-primary-200 dark:border-primary-800 font-mono tracking-wide">
{{ recipe.code }}
</span>
<!-- Version Badge -->
{% if recipe.current_version or recipe.version %}
<span class="badge badge-neutral">
v{{ recipe.current_version.version_number if recipe.current_version else recipe.version }}
</span>
{% endif %}
</div>
<h1 class="text-2xl font-bold text-[var(--text-primary)] mb-2">
{{ recipe.name }}
</h1>
{% if recipe.description %}
<p class="text-sm text-[var(--text-secondary)] leading-relaxed max-w-2xl">
{{ recipe.description }}
</p>
{% endif %}
</div>
<!-- Right: Traceability Badges -->
<div class="flex flex-col gap-2 sm:items-end shrink-0">
{% if lot_number %}
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
text-amber-800 dark:text-amber-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<span class="font-medium">{{ _('Lotto') }}:</span>
<span class="font-mono font-semibold">{{ lot_number }}</span>
</div>
{% endif %}
{% if serial_number %}
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800
text-indigo-800 dark:text-indigo-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
<span class="font-medium">{{ _('Seriale') }}:</span>
<span class="font-mono font-semibold">{{ serial_number }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Task Count Header -->
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-[var(--text-primary)] flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
{{ _('Task da eseguire') }}
</h2>
<span class="badge badge-neutral">
{{ tasks|length }} task
</span>
</div>
<!-- Task Cards -->
{% if tasks %}
<div class="space-y-4">
{% for task in tasks %}
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
<div class="p-5 sm:p-6">
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<!-- Task Number Circle -->
<div class="flex items-center justify-center w-12 h-12 rounded-full shrink-0
bg-primary-50 dark:bg-primary-900/30 border-2 border-primary-200 dark:border-primary-700
text-primary-700 dark:text-primary-300 font-bold text-lg">
{{ loop.index }}
</div>
<!-- Task Info -->
<div class="flex-1 min-w-0">
<!-- Task Header -->
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">
Task {{ loop.index }} {{ _('di') }} {{ tasks|length }}
</span>
</div>
<!-- Task Title -->
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
{{ task.title or task.name or (_('Task') ~ ' ' ~ loop.index) }}
</h3>
<!-- Directive -->
{% if task.directive or task.description %}
<p class="text-sm text-[var(--text-secondary)] leading-relaxed mb-3 line-clamp-2">
{{ task.directive or task.description }}
</p>
{% endif %}
<!-- Meta Row -->
<div class="flex items-center gap-4 flex-wrap">
<!-- Subtask Count -->
{% if task.subtask_count is defined or task.subtasks %}
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<span class="font-medium">
{% if task.subtask_count is defined %}
{{ task.subtask_count }}
{% elif task.subtasks %}
{{ task.subtasks|length }}
{% endif %}
{{ _('misurazioni') }}
</span>
</span>
{% endif %}
<!-- File Attachment -->
{% if task.file_path %}
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
{% if task.file_path.endswith('.pdf') %}
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{% endif %}
<span class="font-medium">{{ _('Allegato') }}</span>
</span>
{% endif %}
</div>
</div>
<!-- Action Button -->
<div class="shrink-0 sm:ml-4">
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
group-hover:shadow-md transition-shadow duration-200">
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Inizia Misure') }}
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<!-- Empty State -->
<div class="text-center py-16">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
bg-[var(--bg-secondary)] mb-4">
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
{{ _('Nessun task disponibile') }}
</h3>
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto">
{{ _('Questa ricetta non ha ancora task definiti.') }}
</p>
</div>
{% endif %}
<!-- Bottom Navigation -->
<div class="mt-8 flex items-center justify-between">
<a href="{{ url_for('measure.select_recipe') }}"
class="btn btn-secondary gap-2">
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
{{ _('Seleziona altra ricetta') }}
</a>
{% if tasks %}
<div class="text-sm text-[var(--text-secondary)]">
{{ tasks|length }} task &middot;
{% set total_subs = namespace(count=0) %}
{% for t in tasks %}
{% if t.subtask_count is defined %}
{% set total_subs.count = total_subs.count + t.subtask_count %}
{% elif t.subtasks %}
{% set total_subs.count = total_subs.count + t.subtasks|length %}
{% endif %}
{% endfor %}
{% if total_subs.count > 0 %}
{{ total_subs.count }} {{ _('misurazioni totali') }}
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
-94
View File
@@ -1,94 +0,0 @@
"""Tests for measure blueprint (client/blueprints/measure.py).
Covers recipe selection, task list, login requirement, and measurement submission.
"""
import pytest
class TestSelectRecipe:
"""GET /measure/select tests."""
def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch):
"""Recipe selection page renders for MeasurementTec role."""
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
]
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
def test_select_recipe_requires_login(self, client):
"""Unauthenticated user is redirected to login."""
resp = client.get("/measure/select", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestTaskList:
"""GET /measure/tasks/<recipe_id> tests."""
def test_task_list_renders(self, logged_in_client, mock_api_client):
"""Task list page renders with recipe and task data."""
# First call: recipe details (dict), second call: tasks list.
# The route calls tasks_resp.get("error") so the mock must return
# a dict (not a bare list) to avoid AttributeError.
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
{
"items": [
{"id": 1, "title": "Task 1", "order_index": 0},
{"id": 2, "title": "Task 2", "order_index": 1},
],
},
]
resp = logged_in_client.get("/measure/tasks/1")
assert resp.status_code == 200
class TestSaveMeasurement:
"""POST /measure/save-measurement tests."""
def test_save_measurement_proxy(self, logged_in_client, mock_api_client):
"""Save measurement forwards data to API and returns 201."""
mock_api_client.post.return_value = {
"id": 1,
"subtask_id": 10,
"task_id": 5,
"value": 9.95,
"pass_fail": "pass",
}
resp = logged_in_client.post(
"/measure/save-measurement",
json={
"subtask_id": 10,
"task_id": 5,
"value": 9.95,
"pass_fail": "pass",
"deviation": 0.05,
},
content_type="application/json",
)
assert resp.status_code == 201
data = resp.get_json()
assert data["id"] == 1
assert data["pass_fail"] == "pass"
def test_save_measurement_missing_fields(self, logged_in_client, mock_api_client):
"""Missing required fields return 400."""
resp = logged_in_client.post(
"/measure/save-measurement",
json={"subtask_id": 10}, # missing task_id and value
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert data["error"] is True
@@ -1,37 +0,0 @@
"""Verify that /measure/select reads STATION_CODE and filters recipes via the server."""
import importlib
from unittest.mock import patch, MagicMock
def _reload_measure(monkeypatch, station_code=None):
"""Reload config and measure module under the given STATION_CODE env."""
if station_code is None:
monkeypatch.delenv("STATION_CODE", raising=False)
else:
monkeypatch.setenv("STATION_CODE", station_code)
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
def test_select_recipe_calls_station_endpoint(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-TEST")
from blueprints import measure as measure_bp_mod
with patch.object(measure_bp_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = [
{"id": 1, "code": "R1", "name": "Recipe 1", "active": True},
]
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
mock_api.get_station_recipes.assert_called_once()
args, kwargs = mock_api.get_station_recipes.call_args
assert args[0] == "ST-TEST" or kwargs.get("station_code") == "ST-TEST"
def test_select_recipe_without_station_code_shows_error(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code=None)
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 503
body = resp.data.lower()
assert b"station_code" in body or b"stazione" in body
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-118
View File
@@ -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())
+21 -3
View File
@@ -24,7 +24,7 @@ services:
server: server:
build: build:
context: ./server context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: tmflow-server container_name: tmflow-server
restart: unless-stopped restart: unless-stopped
@@ -33,6 +33,7 @@ services:
environment: environment:
DB_HOST: mysql DB_HOST: mysql
UPLOAD_DIR: uploads UPLOAD_DIR: uploads
VISION_WORKER_URL: http://vision:8100
volumes: volumes:
- upload_data:/app/uploads - upload_data:/app/uploads
depends_on: depends_on:
@@ -41,10 +42,27 @@ services:
networks: networks:
- tmflow-net - tmflow-net
vision:
build:
context: .
dockerfile: Dockerfile.vision
args:
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
container_name: tmflow-vision
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- tmflow-net
client: client:
build: build:
context: ./client context: .
dockerfile: Dockerfile dockerfile: Dockerfile.frontend
container_name: tmflow-client container_name: tmflow-client
restart: unless-stopped restart: unless-stopped
env_file: env_file:
+22 -4
View File
@@ -24,7 +24,7 @@ services:
server: server:
build: build:
context: ./server context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: tmflow-server container_name: tmflow-server
restart: unless-stopped restart: unless-stopped
@@ -33,6 +33,7 @@ services:
environment: environment:
DB_HOST: mysql DB_HOST: mysql
UPLOAD_DIR: uploads UPLOAD_DIR: uploads
VISION_WORKER_URL: http://vision:8100
volumes: volumes:
- upload_data:/app/uploads - upload_data:/app/uploads
depends_on: depends_on:
@@ -50,10 +51,27 @@ services:
- tmflow-net - tmflow-net
- traefik-net - traefik-net
vision:
build:
context: .
dockerfile: Dockerfile.vision
args:
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
container_name: tmflow-vision
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- tmflow-net
client: client:
build: build:
context: ./client context: .
dockerfile: Dockerfile dockerfile: Dockerfile.frontend
container_name: tmflow-client container_name: tmflow-client
restart: unless-stopped restart: unless-stopped
env_file: env_file:
@@ -93,4 +111,4 @@ networks:
driver: bridge driver: bridge
traefik-net: traefik-net:
external: true external: true
name: root_default name: traefik
+184
View File
@@ -23,6 +23,7 @@ TieMeasureFlow provides a REST API built with FastAPI for managing measurement t
- [Measurements](#measurements) - [Measurements](#measurements)
- [Files](#files) - [Files](#files)
- [Settings](#settings) - [Settings](#settings)
- [Stations](#stations)
- [Statistics](#statistics) - [Statistics](#statistics)
- [Reports](#reports) - [Reports](#reports)
7. [Pagination](#pagination) 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 ### Statistics
All statistics endpoints **require Metrologist role**. All statistics endpoints **require Metrologist role**.
+191
View File
@@ -0,0 +1,191 @@
# Scaletta di collaudo — V3.0.0
Tredici dei quindici punti del 28/07 sono in esercizio e **nessuno li ha ancora
percorsi**. Questa è la scaletta per farlo: ordinata in modo che ogni prova prepari
la successiva, e scritta perché si possa spuntare riga per riga.
Per ogni riga: cosa fare, e **come si vede che è giusto**. Se qualcosa non torna,
annotare il testo esatto dell'eventuale errore in console — non «dava errore».
Totale stimato: **circa 90 minuti**, di cui 30 sul solo punto 3 (il timer, che si
misura aspettando).
---
## 0 · Preparazione (5 min)
| | Cosa | Verifica |
|---|---|---|
| 0.1 | Aprire il sito e fare **ricarica forzata** (`Ctrl+Shift+R`) | CSS e JS sono in cache per 7 giorni: senza questo si collauda la versione di ieri |
| 0.2 | Aprire la **console del browser** (`F12`) e lasciarla aperta per tutto il collaudo | Molte prove si leggono lì, non a schermo |
| 0.3 | Verificare che esistano le ricette `COLLAUDO-A` e `COLLAUDO-B` | Sono in `/measure/select`. Se una sessione precedente le ha sporcate: `docker compose exec server uv run python /tmp/seed_collaudo.py --replace` (cancella anche le misure fatte su di esse) |
**Credenziali del capoturno:** `capoturno` / `Collaudo2026!` — da cambiare prima di
qualunque uso reale.
**Le ricette:**
- **COLLAUDO-A** — lotto obbligatorio, digitazione ammessa, intervallo **2 minuti**.
Cinque task: nota → misura (2 quote) → nota → misura (1 quota) → nota.
- **COLLAUDO-B** — lotto **e** seriale obbligatori, **solo calibro**, una quota.
**Le quote e i valori da usare** (tutte con nominale al centro):
| Quota | Task | Nominale | Conforme | Attenzione | Fuori tolleranza |
|---|---|---|---|---|---|
| 1 · Diametro esterno | A, misura 1 | 10.00 | `10.00` | `10.30` | `12.00` |
| 2 · Spessore parete | A, misura 1 | 25.00 | `25.00` | `25.30` | `27.00` |
| 3 · Lunghezza totale | A, misura 2 | 100.00 | `100.00` | `100.70` | `105.00` |
---
## 1 · Layout — punto 14 (10 min) · **da monitor da scrivania**
Va fatto **prima** e **non dal tablet**: in verticale su un tablet quasi tutte le
larghezze coincidono e il difetto non si vede. Serve uno schermo largo.
| | Cosa | Verifica |
|---|---|---|
| 1.1 | Percorrere Ricette → apri una ricetta → Task → Disegno → Anteprima, e tornare indietro | Il blocco di contenuto **non cambia larghezza** fra una schermata e l'altra |
| 1.2 | Su ognuna, guardare il bordo sinistro del contenuto e quello della barra in alto | Sono **allineati**: il contenuto sta dentro la stessa cornice della navbar |
| 1.3 | Passare da una pagina lunga (che scorre) a una corta (che non scorre) | Niente **salta di lato** di qualche pixel: lo spazio della barra di scorrimento è riservato sempre |
| 1.4 | Entrare in un task di misura e uscirne | Stesso controllo: nessuno spostamento orizzontale |
| 1.5 | Admin → Utenti, Stazioni, Impostazioni; poi il proprio Profilo | Utenti e Stazioni sono larghe, Impostazioni e Profilo strette — ma con **gli stessi margini esterni** |
| 1.6 | **Dal tablet**, entrare in un task di misura | Il piede con «Fine ciclo misura» e il tastierino è visibile **senza scorrere**, anche quando la barra dell'indirizzo compare e sparisce |
---
## 2 · Niente arriva dalla rete — punto 12 (15 min)
Da fare **adesso**, perché la Content-Security-Policy è appena entrata in vigore: se
una libreria avesse bisogno di un permesso non concesso, il sintomo compare ora.
> **Attenzione al metodo.** Il sistema è oggi su una VPS pubblica: staccare la rete
> significherebbe perdere anche il server, quindi non prova niente. La prova
> equivalente e più diretta è guardare **dove vanno le richieste**. Il giro a rete
> davvero staccata si fa in reparto, sull'installazione on-premise.
| | Cosa | Verifica |
|---|---|---|
| 2.1 | Console → scheda **Rete**, spuntare «Disabilita cache», ricaricare | Ordinando per dominio compare **un solo dominio**: `tieflow.tielogic.xyz`. Nessun `cdn`, `jsdelivr`, `cloudflare`, `googleapis`, `plot.ly` |
| 2.2 | Ripetere su: login, selezione ricetta, esecuzione task, editor annotazioni (`/maker`), dashboard statistiche | Sempre un dominio solo. La dashboard è quella che carica Plotly, l'editor quella che carica Fabric |
| 2.3 | Aprire un task che ha un **disegno PDF** allegato | Il disegno si vede. È la prova del *worker* di PDF.js: se cercasse la rete, tutto sembrerebbe a posto fino a questo momento |
| 2.4 | Con la console aperta su ogni pagina del giro | **Zero** messaggi che iniziano con `Refused to load…` o `…violates the following Content Security Policy directive`. Se ce n'è uno, copiarlo per intero: dice quale permesso manca |
| 2.5 | Generare un **report PDF** dalle statistiche | Il PDF esce. È generato sul server, non nel browser, ma vale la pena vederlo funzionare nel giro completo |
---
## 3 · Le regole della ricetta — punti 8 e 9 (10 min) · **COLLAUDO-B**
| | Cosa | Verifica |
|---|---|---|
| 3.1 | `/measure/select`, **senza** compilare lotto e seriale | Sulla scheda di COLLAUDO-B il pulsante è spento e dice **«Compila prima: lotto, seriale»** |
| 3.2 | Compilare **solo** il lotto | Il pulsante resta spento e ora chiede solo il **seriale** |
| 3.3 | Provare ad andare a mano su `/measure/start/<id>` di COLLAUDO-B senza seriale | Rimanda alla selezione con il messaggio «Compila prima:». La regola non si aggira dall'indirizzo |
| 3.4 | Compilare entrambi e avviare | Entra nel task |
| 3.5 | Guardare il tastierino | I tasti numerici **non ci sono affatto** — non sono grigi, non ci sono. Restano «cancella» e «conferma» |
| 3.6 | Con un **calibro USB** collegato, prendere una lettura | Il valore entra e si salva |
| 3.7 | *(facoltativo, per chi vuole la prova dura)* Provare a salvare un valore digitato aggirando lo schermo | Il server risponde **422**: la regola vive lì, non nella pagina |
---
## 4 · Il primo ciclo e l'avvio produzione — punti 10 e 11 (10 min) · **COLLAUDO-A**
> Da qui in avanti si lavora su COLLAUDO-A. **Il timer non parte subito**: prima si
> fa un ciclo completo, poi compare «Avvio Produzione». È voluto — il primo pezzo è
> l'attrezzaggio, non produzione.
| | Cosa | Verifica |
|---|---|---|
| 4.1 | Selezione ricetta → COLLAUDO-A → lotto `LOT-TEST-01`**AVVIA IN SEQUENZA** | Si apre **il primo task** (la nota «Preparazione del pezzo»), **non** l'elenco dei task |
| 4.2 | Leggere la nota | «a sinistra» è in **grassetto**, le righe vanno a capo e la riga vuota si vede (punto 11) |
| 4.3 | Guardare i due pulsanti in alto a destra | Sono **«Lista task»** e **«Completato»**. Su una nota «Completato» è attivo |
| 4.4 | Premere «Completato» | Passa al task 2, «Misura 1 — diametro e spessore» |
| 4.5 | Guardare il pulsante in basso a destra | **«Mancano 2 quote»**, grigio e non cliccabile |
| 4.6 | Misurare la quota 1 con `10.00` e confermare | Verde (conforme). Il pulsante in basso ora dice **«Manca 1 quota»** |
| 4.7 | **Prova chiave del punto 10**: premere «Lista task» adesso, a metà | Nel'elenco il task «Misura 1» porta **«Incompiuto 1/2»** con il bordo ambra. Gli altri non dicono niente |
| 4.8 | Premere **«Visualizza singolo TASK»** su quel task | Ci si torna dentro, con la quota già presa |
| 4.9 | Misurare la quota 2 con `25.00` | Il pulsante in basso diventa blu: **«Fine ciclo misura»** |
| 4.10 | Premerlo | Compare il verde **«Task successivo»** (non un secondo «Completato») |
| 4.11 | Guardare sotto il piede della pagina | Compare il pulsante verde **«Avvio Produzione»** |
| 4.12 | Premere «Avvio Produzione» | Parte il conto alla rovescia da **2 minuti**. Da qui in avanti compaiono «Fermo linea» e «Fine produzione» |
---
## 5 · Il fuori tolleranza — punto 5 (15 min)
La parte più importante del collaudo: fino a ieri questo gate era una modale che si
poteva chiudere.
| | Cosa | Verifica |
|---|---|---|
| 5.1 | Tornare su «Misura 1». Cliccare la **quota 1** in alto per rimisurarla, e inserire `12.00` | Si salva ed è **rossa**. La misura sbagliata resta a registro: è un dato, non un errore da nascondere |
| 5.2 | Provare a passare alla **quota 2** e salvare un valore | **Rifiutato.** Compare l'avviso che serve l'autorizzazione |
| 5.3 | Guardare «Completato» in alto | È **spento**, e passandoci sopra dice «Serve l'autorizzazione del capoturno» |
| 5.4 | **Ricaricare la pagina** (`F5`) | Il blocco **è ancora lì**. Ricaricare non è una via d'uscita |
| 5.5 | **La via d'uscita senza capoturno**: cliccare di nuovo la quota 1 e rimisurarla con `10.00` | Il blocco **si scioglie**. È voluto: il calibro scivola, il pezzo si riposiziona, e chiamare il capoturno per questo sarebbe assurdo |
| 5.6 | Rifarlo: quota 1 con `12.00`, e stavolta **autorizzare** con `capoturno` / `Collaudo2026!` | Il blocco si scioglie e la misura resta segnata come autorizzata |
| 5.7 | Provare ad autorizzare con una password sbagliata | Rifiutato, e il blocco **resta** |
| 5.8 | Provare ad autorizzare con un utente **senza** ruolo Supervisor (es. l'operatore stesso) | Rifiutato: non basta essere collegati, serve il ruolo |
| 5.9 | Rimettere `10.00` sulla quota 1 e `25.00` sulla 2, chiudere il ciclo | Si prosegue normalmente |
---
## 6 · Il ciclo di misura — punto 3 (30 min, quasi tutti di attesa)
| | Cosa | Verifica |
|---|---|---|
| 6.1 | Con il conto alla rovescia in corso, **cambiare task** | Il tempo residuo **prosegue**, non riparte da 2 minuti |
| 6.2 | **Ricaricare la pagina** (`F5`) | Idem: il tempo è lo stesso. Vive sul server, non nella pagina |
| 6.3 | Fermarsi su una **nota** e lasciare scadere l'intervallo | A ~5 secondi dalla scadenza compare l'avviso e **suona il cicalino** |
| 6.4 | Non toccare niente | Alla scadenza si viene portati **al primo task di misura** della ricetta, non a quello dove si era |
| 6.5 | Fare un ciclo completo: misura 1 (2 quote) → nota → **misura 2** (quota 3, `100.00`) → «Fine ciclo misura» | Chiudendo il ciclo **sull'ultimo task di misura** l'intervallo **riparte da 2 minuti** |
| 6.6 | Ripetere ma chiudendo il ciclo sulla **prima** misura | L'intervallo **non** riparte: il ciclo si chiude solo sull'ultimo task di misura |
| 6.7 | **La prova più delicata**: stare **dentro** un task di misura, con una quota già presa e una da prendere, e lasciare scadere l'intervallo | **Non** si viene spostati. Il conteggio diventa **rosso** e prosegue **oltre lo zero**, mostrando il ritardo. Chi sta misurando va lasciato finire |
| 6.8 | Usare **«Rimisura»** dopo aver completato le quote | Registra una seconda lettura dello stesso pezzo **senza** far ripartire l'intervallo. Girare il pezzo e rimisurare non è un pezzo nuovo |
---
## 7 · Fermo linea e fine produzione — punto 6 (10 min)
| | Cosa | Verifica |
|---|---|---|
| 7.1 | Dentro un task di misura, premere **«Fermo linea»** → credenziali capoturno | Il conto alla rovescia **si ferma** |
| 7.2 | Ricaricare la pagina | È ancora fermo: lo stato è sul server |
| 7.3 | Premere **«Ripresa»** → credenziali | Riparte |
| 7.4 | Premere **«Fine produzione»** → credenziali | La produzione si chiude |
| 7.5 | Sul server: `docker compose exec server ls uploads/statistics/` | C'è un file `production_<id>_<data>.csv` |
| 7.6 | Aprire il file | Contiene **tutte** le misure della produzione, compresi i `12.00`. Sulla riga autorizzata le colonne `authorised_by` e `authorised_at` sono **valorizzate**: il file mostra il guasto **e** la decisione |
| 7.7 | Aprire il file in Excel | Le accentate si leggono, il separatore è quello configurato |
---
## 8 · La statistica resta separata — punto 15 (5 min)
| | Cosa | Verifica |
|---|---|---|
| 8.1 | Collegarsi con un utente che ha **solo** il ruolo MeasurementTec | In barra **non c'è** la voce Statistiche |
| 8.2 | Percorrere tutto il flusso operatore guardando ogni schermata | Nessun collegamento porta alla statistica, in nessun punto |
| 8.3 | Andare a mano su `/statistics/dashboard` | **403**: il ruolo non basta |
| 8.4 | Verificare che le misure ci siano comunque | Con un utente Metrologist, le misure appena fatte compaiono in dashboard. Si registra sempre, si consulta a parte |
---
## Cosa annotare
Per ogni riga che non torna:
1. **Numero della riga** di questa scaletta.
2. **Cosa è successo** invece di quello che c'è scritto.
3. Se c'è un errore in console, il **testo esatto**, per intero.
4. Se è un problema di aspetto, uno **screenshot** e su **quale schermo** (tablet o
monitor, e in che orientamento) — per il punto 14 è l'informazione che manca.
## Cosa questa scaletta non copre
- Il **punto 4** (numero di tentativi prima del capoturno) non è implementato: è
fermo sulle risposte del cliente (D-6, D-7, D-9).
- Il **cicalino** è il suono del browser. Se serva una colonnina luminosa è D-5.
- Il giro a **rete davvero staccata** si fa in reparto, sull'installazione
on-premise: qui si prova l'equivalente (nessuna richiesta esce dal dominio).
- Il **carico** con venti tablet insieme non è mai stato misurato.
+4 -3
View File
@@ -134,10 +134,11 @@ SSL_KEYFILE=
| `CLIENT_HOST` | string | 0.0.0.0 | Flask client bind address | | `CLIENT_HOST` | string | 0.0.0.0 | Flask client bind address |
| `CLIENT_PORT` | int | 5000 | Flask client port | | `CLIENT_PORT` | int | 5000 | Flask client port |
| `SERVER_CORS_ORIGINS` | string | http://localhost:5000 | Comma-separated CORS origins | | `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 | | `MAX_UPLOAD_SIZE_MB` | int | 50 | Maximum upload file size in MB |
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute | | `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute, per real client IP |
| `RATE_LIMIT_GENERAL` | int | 100 | General requests per minute | | `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_CERTFILE` | string | (empty) | Path to SSL certificate (production) |
| `SSL_KEYFILE` | string | (empty) | Path to SSL private key (production) | | `SSL_KEYFILE` | string | (empty) | Path to SSL private key (production) |
+2 -2
View File
@@ -10,7 +10,7 @@ TieMeasureFlow supports complete internationalization (i18n) with:
## Directory Structure ## Directory Structure
``` ```
client/ src/frontend/flask_app/
├── translations/ # Flask-Babel translations ├── translations/ # Flask-Babel translations
│ ├── babel.cfg # Extraction config │ ├── babel.cfg # Extraction config
│ ├── it/LC_MESSAGES/ │ ├── it/LC_MESSAGES/
@@ -99,7 +99,7 @@ flash(_("Profilo aggiornato con successo"))
After editing .po files: After editing .po files:
```bash ```bash
cd client cd src/frontend/flask_app
python compile_translations.py python compile_translations.py
``` ```
+50
View File
@@ -0,0 +1,50 @@
# Documentazione TieMeasureFlow
Indice della documentazione del progetto.
## Stato e direzione
| Documento | Scopo |
|---|---|
| [`../TieMeasureFlow_modifiche_2026-07-28.md`](../TieMeasureFlow_modifiche_2026-07-28.md) | **Il piano di lavoro corrente**: i quindici punti del sopralluogo del 28/07 e le decisioni in attesa del cliente (D-1…D-9). |
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V3.0.0), punto per punto, e cosa non è ancora stato provato sul campo. |
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta, in ordine: collaudo, punto 4, innesto GAIA, installazione a Tràfilo. Include la mappatura con le Fasi rev04 di aprile. |
| [`architecture/LAYOUT.md`](architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava fra le viste e la regola che lo tiene fermo. |
| [`../src/frontend/flask_app/static/vendor/VERSIONS.md`](../src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte SHA-256, come aggiornarle. |
## 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). |
| [`COLLAUDO.md`](COLLAUDO.md) | **Scaletta di collaudo V3.0.0**: cosa provare, in che ordine, con quali valori, e come si vede che è giusto. |
## Piani dettagliati TDD (rev04) — storici
> Il piano rev04 di aprile è **superato** dal documento del 28/07. La mappatura fra
> le sue sette Fasi e i quindici punti correnti è in fondo a `architecture/ROADMAP.md`.
| 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
View File
@@ -37,6 +37,8 @@ TieMeasureFlow is a web-based measurement management system that enables teams t
| **Subtask** | Individual measurement point with tolerance limits (UTL/UWL/LWL/LTL) | | **Subtask** | Individual measurement point with tolerance limits (UTL/UWL/LWL/LTL) |
| **Measurement** | Individual recorded value with automatic pass/fail/warning status | | **Measurement** | Individual recorded value with automatic pass/fail/warning status |
| **Lot/Serial** | Traceability fields linking measurements to physical parts | | **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 ### Architecture
@@ -175,23 +177,25 @@ TieMeasureFlow has four primary roles. Users can have multiple roles simultaneou
### Admin ### Admin
**Purpose:** System administration and user management **Purpose:** System administration, user management and station deployment
**Permissions:** **Permissions:**
- Create/edit/delete users - Create/edit/delete users
- Assign roles to users - Assign roles to users
- Regenerate user API keys - Regenerate user API keys
- Create/edit/delete stations and assign recipes to them
- Configure system settings - Configure system settings
- Upload company logo - Upload company logo
- Manage CSV export settings - Manage CSV export settings
**Access:** **Access:**
- Menu: **Admin** → User Management - Menu: **Admin** → Utenti / Stazioni
- Can see: All users and system settings - Can see: All users, all stations and system settings
**Typical Tasks:** **Typical Tasks:**
- Onboard new users - Onboard new users
- Reset lost API keys - 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 - Configure locale/format settings
- Upload company branding - Upload company branding
- Manage system access - Manage system access
@@ -276,6 +280,12 @@ When you edit a recipe (add/remove tasks, change tolerances), a **new version**
## MeasurementTec Workflow ## 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 ### Select a Recipe
**Method 1: Search** **Method 1: Search**
@@ -503,6 +513,82 @@ If user loses their API key:
4. New key is generated and displayed 4. New key is generated and displayed
5. Provide new key to user (display once only) 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 ### System Settings
#### Configure CSV Export #### Configure CSV Export
+98
View File
@@ -0,0 +1,98 @@
# Cornice delle pagine
Nasce dal punto 14 del documento del 28/07: *«le dimensioni delle viste cambiano a
seconda del menu; passando da una schermata all'altra la finestra non mantiene
proporzioni stabili»*. La segnalazione era di un operatore e non era ancora stata
circoscritta sul codice. Questo è l'esito della circoscrizione e la regola che ne
è uscita.
## Cosa succedeva
Tre meccanismi distinti, tutti verificabili sul codice senza avere il tablet in mano.
**1. Ogni vista si dichiarava la propria larghezza.** Sette valori diversi su
diciassette template, e nessuno coincideva con quello della navbar, che sta a
`max-w-7xl` su tutte le pagine. Il contenuto risultava quindi disallineato dalla
barra sopra di sé, e il disallineamento cambiava da pagina a pagina.
| vista | prima | dopo |
|---|---|---|
| `measure/select_recipe.html` | `max-w-7xl` `py-8` | `tmf-page` |
| `measure/task_list.html` | `max-w-5xl` `py-8` | `tmf-page` |
| `measure/task_complete.html` | `max-w-7xl` `py-6` | `tmf-page` |
| `maker/recipe_list.html` | `max-w-6xl` `py-8` | `tmf-page` |
| `maker/task_editor.html` | `max-w-5xl` `py-8` | `tmf-page` |
| `maker/task_drawing.html` | `max-w-5xl` `py-8` | `tmf-page` |
| `maker/recipe_preview.html` | `max-w-5xl` `py-8` | `tmf-page` |
| `admin/stations.html` | `max-w-7xl` `py-6` | `tmf-page` |
| `admin/users.html` | `max-w-7xl` `py-6` | `tmf-page` |
| `statistics/dashboard.html` | `max-w-7xl` `py-6` | `tmf-page` |
| `admin/settings.html` | `max-w-3xl` `py-6` | `tmf-page tmf-page-narrow` |
| `auth/profile.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
| `maker/recipe_editor.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
| `maker/version_history.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
| `errors/station_not_configured.html` | `max-w-2xl` `py-8` | `tmf-page tmf-page-narrow` |
Il percorso dell'operatore — quello da cui è arrivata la segnalazione — faceva
`1280px → 1024px → tutto schermo → 1280px` in quattro passaggi.
**2. Il padding verticale non era lo stesso.** `py-8` e `py-6` mescolati fra viste
consecutive: la prima card si trovava a un'altezza diversa a ogni cambio di
schermata.
**3. La barra di scorrimento appariva e spariva.** È una barra classica da 8px
(`themes.css`, `::-webkit-scrollbar`), quindi occupa spazio nel layout. Una pagina
lunga la mostrava, una corta no, e la schermata di misura la toglie sempre
(`body { overflow: hidden }`): a ogni navigazione tutto il contenuto centrato —
navbar compresa — si spostava di 8px in orizzontale.
## La regola
Due classi in `static/css/themes.css`, nessuna larghezza nei template.
- **`.tmf-page`** — la cornice: `max-width: 80rem`, padding `1rem / 1.5rem / 2rem`
ai tre breakpoint, `padding-block: 1.5rem`. Sono esattamente i valori della
navbar (`max-w-7xl px-4 sm:px-6 lg:px-8`), così il contenuto ci si allinea.
- **`.tmf-page-narrow`** — si aggiunge alla prima e stringe a `56rem`. È per i
moduli: un campo di testo largo 1280px non si compila meglio.
- **`html { scrollbar-gutter: stable }`** — lo spazio della barra è riservato
sempre, anche dove la pagina non scorre.
- **`body.h-screen { height: 100dvh }`** — la schermata di misura è alta quanto la
finestra vera. `100vh` su Android e iOS è l'altezza che la pagina avrebbe con la
barra dell'indirizzo nascosta: su un tablet il piede della schermata — dove
stanno «Fine ciclo misura» e il tastierino — finiva sotto il bordo, e ricompariva
quando la barra si ritraeva.
Due larghezze in tutto il prodotto al posto di sette, e il criterio è il tipo di
pagina: liste, tabelle e tele di disegno stanno larghe, i moduli stanno stretti.
## Le due eccezioni
Hanno geometria propria per un motivo, e sono elencate in
`tests/test_layout_shell.py` perché non sembrino dimenticate.
- **`auth/login.html`** — schermata piena senza navbar: non c'è niente a cui
allinearsi.
- **`measure/task_execute.html`** — la schermata di misura è un pannello a tutta
altezza che non deve scorrere mentre un operatore sta misurando
(`h-screen overflow-hidden`, footer nascosto). È l'unica vista in cui il cambio
di forma è voluto, ed è anche l'unica in cui l'operatore si ferma a lavorare:
entrarci e uscirne resta un salto, ma ora è l'unico.
## Cosa resta da verificare sul campo
La segnalazione non è mai stata riprodotta su un dispositivo: quanto sopra viene
dalla lettura del codice, non da una prova. Quello che è dimostrato è che il
layout *poteva* muoversi per quattro motivi distinti e che ora non può più per
nessuno dei quattro.
Se l'operatore vedesse ancora spostamenti, il sospetto successivo è la **tastiera
virtuale**: quando compare, il browser ridimensiona la finestra, e con `100dvh` la
schermata di misura si ridimensiona con lei invece di scorrere sotto. È il
comportamento giusto per il tastierino a schermo, ma va guardato con un calibro
USB collegato, dove la tastiera di sistema non dovrebbe comparire affatto.
Da chiedere all'operatore che ha aperto la segnalazione: **su quale schermo** l'ha
vista. Su un tablet in verticale la maggior parte delle larghezze qui sopra
collassa allo stesso valore e il difetto non si vede; su un monitor da scrivania
si vede tutto.
+146
View File
@@ -0,0 +1,146 @@
# Roadmap TieMeasureFlow — V3.0.0 → installazione a Tràfilo
> Aggiornata al 2026-07-28. Aggiornare a ogni punto chiuso.
## Dove siamo
La roadmap rev04 di aprile (Fasi 1-7, milestone M1/M2) è **superata dai fatti**: il
sopralluogo del 28/07 ha prodotto un elenco di quindici punti concreti che è oggi il
piano di lavoro. Buona parte delle vecchie Fasi 2 e 4 è dentro quei punti ed è già
fatta; il resto è confluito o decaduto. La mappatura è in fondo, per non perdere il
filo con i documenti di aprile.
- Piano corrente: [`../../TieMeasureFlow_modifiche_2026-07-28.md`](../../TieMeasureFlow_modifiche_2026-07-28.md)
- Stato di dettaglio: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
- Piano rev04 (storico): [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md)
**Scadenza che comanda tutto: l'installazione on-premise a Tràfilo è prevista per
settembre 2026.**
## I quindici punti
| # | Punto | Stato |
|---|---|---|
| 1 | Memoria della produzione in corso | ✅ fatto |
| 2 | Tipo di task esplicito | ✅ fatto |
| 3 | Loop di misura e ripetizione | ✅ fatto |
| 4 | Limite di tentativi prima del capoturno | ⛔ **fermo** su D-6, D-7, D-9 |
| 5 | Avanzamento solo se in tolleranza | ✅ fatto |
| 6 | Fermo linea e Fine produzione con effetto | ✅ fatto (innesto GAIA fermo su D-1, D-2) |
| 7 | Gestione delle stazioni | ✅ fatto |
| 8 | Tracciabilità obbligatoria | ✅ fatto |
| 9 | Blocco dell'inserimento manuale | ✅ fatto |
| 10 | Interfaccia operatore: sequenza e conferme | ✅ fatto |
| 11 | Formattazione delle descrizioni | ✅ fatto |
| 12 | Funzionamento senza internet | ✅ fatto |
| 13 | Generazione task dalla scheda tecnica con l'AI | — fuori offerta (quotazione su D-8) |
| 14 | Stabilità del layout | ✅ fatto |
| 15 | Statistica separata dalla registrazione | ✅ fatto |
## Cosa resta, in ordine
### 1. Collaudo sul campo — *da fare adesso, non serve nessuna risposta*
È il lavoro più urgente rimasto, ed è l'unico che non dipende da nessuno. Tredici
punti sono in esercizio e **nessuno li ha percorsi su un tablet**.
Le ricette `COLLAUDO-A` e `COLLAUDO-B` sono già sul sistema, con un utente
`capoturno` (`scripts/seed_collaudo.py`). Da verificare, in quest'ordine:
| Cosa | Come si vede che è giusto |
|---|---|
| Ciclo di misura (3) | Il conto alla rovescia sopravvive al cambio task, prosegue in rosso oltre lo zero, riporta alla misura |
| Fuori tolleranza (5) | Con `12.00` sulla prima quota non si passa alla seconda finché il capoturno non autorizza, o finché non si rimisura dentro |
| Tracciabilità (8) | Su `COLLAUDO-B` l'avvio non parte finché lotto e seriale non ci sono |
| Inserimento manuale (9) | Su `COLLAUDO-B` il tastierino numerico non c'è affatto |
| Sequenza (10) | La ricetta si apre sul primo task; un task lasciato a metà appare «Incompiuto 1/2» nella lista |
| Senza rete (12) | Staccare la rete e percorrere login → ricetta → task → annotazione → statistiche → report, console aperta |
| Layout (14) | **Da un monitor da scrivania**, non solo dal tablet: è lì che il difetto si vedeva |
Il giro del punto 12 va fatto **ora**, perché la Content-Security-Policy è appena
entrata in vigore: se una libreria avesse bisogno di un permesso non concesso, il
sintomo compare adesso.
### 2. Punto 4 — appena arrivano le risposte
Numero di tentativi prima del capoturno. Serve sapere **D-7** (quanti, e se uguali
per tutte le ricette), **D-6** (password, PIN o badge) e **D-9** (se cambiare i
parametri crea una nuova versione). Il resto dell'impianto è pronto: il blocco per
fuori tolleranza e l'autorizzazione del capoturno esistono già, manca il contatore.
Stima, a decisioni chiuse: **2-3 giorni**.
### 3. Innesto verso GAIA — fermo su D-1 e D-2
Avvio produzione, fermo linea e fine produzione hanno già lo stato e gli eventi; la
fine produzione emette già il file di statistica. Manca solo il canale verso il
gestionale, e non si può nemmeno disegnare finché non si sa **come** si parla con
GAIA (D-1) e **da dove** (D-2).
Stima, a protocollo noto: **1-2 settimane**, molto dipendente dalla risposta.
### 4. Installazione a Tràfilo — settembre
| Cosa | Blocco |
|---|---|
| Macchina server, spazio disco | D-4 |
| Immagini Docker portate in reparto, non repository da compilare sul posto | — |
| Colonnina luminosa, se serve | D-5 |
| Validazione rete e macchine con l'IT | D-4 |
Nota: **la costruzione delle immagini richiede rete** (npm, apt, uv); è l'esecuzione
a non richiederla. In reparto va portata l'immagine già costruita.
## Decisioni in attesa del cliente
Da girare a Tràfilo tramite Menoncin. Le prime due e la D-4 hanno l'orizzonte di
settembre; D-6 e D-7 bloccano lavoro che sappiamo già fare.
| ID | Decisione | Blocca | Chi risponde |
|---|---|---|---|
| **D-1** | Protocollo del gestionale GAIA: servizi web, database condiviso, file? | innesto GAIA (punto 6) | IT Tràfilo + fornitore GAIA |
| **D-2** | Rete e credenziali per raggiungere GAIA | come sopra | IT Tràfilo |
| ~~D-3~~ | ~~Una app per stazione o una sola?~~ **Decisa il 28/07**: dati sul server, app di stazione su ogni PC | — | chiusa; validazione in D-4 |
| **D-4** | Server: quale macchina, quanto spazio disco (database **e** disegni) | installazione | IT Tràfilo |
| **D-5** | Cicalino: basta il suono del browser o serve una colonnina luminosa? | punto 3 (completamento) | Tràfilo |
| **D-6** | Autorizzazione capoturno: password come oggi, o PIN / badge? Venti volte al giorno la password è attrito | punti 4, 5, 6 | Tràfilo |
| **D-7** | Quanti tentativi prima del capoturno, e uguali per tutte le ricette? | punto 4 | Tràfilo |
| **D-8** | Schede tecniche: quante, formato standard, conversione una-tantum o funzione permanente? | punto 13 e la sua quotazione | Tràfilo |
| **D-9** | Cambiare i parametri di una ricetta (timer, tentativi) crea una nuova versione? | punti 3, 4 | noi, con conferma cliente |
Una decisione già presa che va **confermata**: la migrazione 009 ha impostato
`allow_manual_input = 1` su tutte le ricette esistenti, per conservare il
comportamento in essere invece di irrigidire di colpo ricette già in uso. Va deciso
ricetta per ricetta quali devono passare a solo calibro.
## Tech debt
| Item | Priorità | Note |
|---|---|---|
| `components/barcode_scanner.html` dipende da `html5-qrcode`, mai caricata | Media | Codice morto: il componente non è incluso da nessuna parte e il lettore che l'operatore usa è un campo di testo. Da rimuovere, o da completare portando la libreria in `static/vendor/` |
| Pagina `task_complete`: riga vuota segnalata in alcuni scenari | Media | Segnalazione di aprile mai riprodotta. Da verificare durante il collaudo, ora che il flusso è cambiato |
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato: impatta i deploy esistenti |
| Header `X-API-Key``X-Api-Key` | Bassa | Breaking per i deploy esistenti |
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 |
| Test di carico a 20 tablet reali | Bassa | La capacità è dimensionata ma mai misurata sotto carico vero |
I quattro test rotti tracciati nello snapshot V2.0.0 non risultano più: la suite è a
360 pass, 0 fail.
## Mappatura con la roadmap rev04 di aprile
Per chi torna sui documenti di aprile e non ritrova le Fasi.
| Fase rev04 | Che fine ha fatto |
|---|---|
| 1 — Stazioni per-tablet | Chiusa in V2.0.0, ampliata dal punto 7 |
| 2 — Ruolo Capoturno + override | Assorbita dai punti 5 e 6: il ruolo `Supervisor` esiste e l'autorizzazione resta scritta sulla misura. L'override a token breve dipende da D-6 |
| 3 — Editor ricetta a blocchi | Sostituita dal punto 2 (tipo di task dichiarato), che risolve il problema vero senza riscrivere l'editor |
| 4 — Workflow operatore | Assorbita dai punti 3, 4, 10: timer e sequenza sono fatti, i tentativi sono il punto 4 |
| 5 — Import GAIA | Diventata l'innesto del punto 6, ferma su D-1 e D-2 |
| 6 — Deploy industriale (registry + Watchtower) | Ridimensionata: con una rete isolata l'aggiornamento automatico non ha senso. Restano immagini versionate portate a mano |
| 7 — Hardening | Parzialmente assorbita: CSP, versioni congelate e impronte sono entrate col punto 12 |
Le vecchie decisioni `D-0.x` sono confluite nelle `D-x` qui sopra: D-0.1→D-1,
D-0.2→D-2, D-0.4→D-5, D-0.5→D-9, D-0.6→D-6. D-0.8 (nome del ruolo capoturno) è
chiusa: si chiama `Supervisor`. D-0.7 (auto-logout) era già risolta.
+192
View File
@@ -0,0 +1,192 @@
# Stato Progetto TieMeasureFlow — V3.0.0
> Snapshot al 2026-07-28. Aggiornare ad ogni milestone.
> Lo snapshot V2.0.0 è in [`../archive/STATO_PROGETTO_V2.0.0_2026-04-27.md`](../archive/STATO_PROGETTO_V2.0.0_2026-04-27.md).
## Versione corrente
**V3.0.0** (in sviluppo, branch `V3.0.0`). Versione precedente: `V2.0.0`.
In esercizio su `tieflow.tielogic.xyz` (Docker Compose + Traefik + Let's Encrypt),
schema alla migrazione `010_meas_authorisation`.
## Sintesi esecutiva
V2.0.0 aveva chiuso stazioni per-tablet, ristrutturazione monorepo con `uv` e la
tenuta multi-tablet. **V3.0.0 nasce dal sopralluogo del 28/07 e lavora sui quindici
punti** raccolti in [`TieMeasureFlow_modifiche_2026-07-28.md`](../../TieMeasureFlow_modifiche_2026-07-28.md).
Il filo che li tiene insieme: fino a V2.0.0 il sistema *registrava* misure ma non
*governava* la produzione. Le regole vivevano nell'interfaccia — una modale che si
poteva chiudere, un timer che moriva al cambio pagina, un tipo di task dedotto dalla
presenza di quote. V3.0.0 le sposta sul server, dove non si aggirano.
Tredici punti su quindici sono fatti e in esercizio. Uno è fermo sulle risposte del
cliente, uno è fuori offerta.
## I quindici punti
| # | Punto | Stato | Dove vive |
|---|---|---|---|
| 1 | Memoria della produzione in corso | ✅ | `production_runs`, `production_service` |
| 2 | Tipo di task esplicito | ✅ | `RecipeTask.task_type`, migrazione 007 |
| 3 | Loop di misura e ripetizione | ✅ | `production-clock.js`, `production_service`, migrazione 008 |
| 4 | Limite di tentativi prima del capoturno | ⛔ **Fermo su D-6, D-7, D-9** | — |
| 5 | Avanzamento solo se in tolleranza | ✅ | `measurement_service.pending_authorisation`, migrazione 010 |
| 6 | Fermo linea e Fine produzione con effetto | ✅ | `production_service`, `production_export_service` |
| 7 | Gestione delle stazioni | ✅ | `station_service`, `/admin/stations` |
| 8 | Tracciabilità obbligatoria | ✅ | `Recipe.requires_lot/serial`, migrazione 009 |
| 9 | Blocco dell'inserimento manuale | ✅ | `Recipe.allow_manual_input`, `numpad.js` |
| 10 | Interfaccia operatore: sequenza e conferme | ✅ | `/measure/start`, `task-progress`, `task_list`, `task_execute` |
| 11 | Formattazione delle descrizioni | ✅ | filtro `rich_text`, `rich-text.js` |
| 12 | Funzionamento senza internet | ✅ | `static/vendor/`, CSP in `app.py` |
| 13 | Generazione task dalla scheda tecnica con l'AI | — **Fuori offerta** | — |
| 14 | Stabilità del layout | ✅ | `.tmf-page` in `themes.css`, [`LAYOUT.md`](LAYOUT.md) |
| 15 | Statistica separata dalla registrazione | ✅ | `@role_required("Metrologist")`, test dedicato |
## Cosa è entrato in V3.0.0
### Produzione come stato, non come pulsante (punti 1, 6)
- Tabelle `production_runs` e `production_events`; una produzione si apre su una
stazione, accumula eventi (`start`, `cycle_completed`, `task_measured`,
`remeasure`, `line_stop`, `resume`, `close`) e si chiude.
- Fermo linea, ripresa e fine produzione richiedono le credenziali del capoturno e
**cambiano lo stato**: prima erano modali che si chiudevano.
- La fine produzione emette il file di statistica dell'intera produzione
(`production_export_service`), CSV con i delimitatori configurati, marcando le
misure come esportate. Il punto d'innesto verso GAIA è lì accanto, in attesa di
D-1 e D-2.
### Il ciclo di misura (punto 3)
- L'intervallo della ricetta è calcolato dal server (`seconds_to_next_measurement`,
`overdue`, `server_time`): sopravvive al cambio task, che è un ricaricamento di
pagina, e non si azzera cambiando schermata.
- Il conteggio **prosegue oltre lo zero**: il ritardo si vede, in rosso.
- A scadenza l'operatore viene riportato al primo task di misura della ricetta, con
cinque secondi di preavviso; chi sta già misurando viene lasciato finire.
- Cicalino via WebAudio, nessun file audio da caricare.
- `remeasure` distingue una seconda lettura dello stesso pezzo da un ciclo nuovo:
girare il pezzo e rimisurare non deve far ripartire l'intervallo.
### Le regole della ricetta (punti 8, 9)
- `requires_lot`, `requires_serial`, `allow_manual_input` per ricetta, verificati in
`measurement_service`: **ogni porta d'ingresso passa di lì**, barcode compreso.
- Con `allow_manual_input` a falso il tastierino non viene disegnato — non nascosto:
il markup non esiste — e il server rifiuta comunque un valore digitato a mano.
- Il rilevamento del calibro USB è stato reso più tollerante (Enter veloce), perché
con la regola stretta una lettura corta come `9.5` veniva scambiata per digitazione.
### Il fuori tolleranza (punto 5)
- Una quota fuori tolleranza e non autorizzata **blocca** il salvataggio della quota
successiva e la chiusura del ciclo (409).
- Conta l'**ultima** lettura di ogni quota: rimisurare la stessa quota resta
possibile — il calibro scivola, il pezzo si riposiziona — e una lettura dentro i
limiti scioglie il blocco senza chiamare nessuno.
- L'autorizzazione del capoturno resta scritta sulla misura (`supervisor_id`,
`authorised_at`) e finisce in due colonne del CSV di produzione: il file mostra il
guasto **e** la decisione.
- Quanti tentativi siano ammessi è il punto 4, non questo.
### L'interfaccia dell'operatore (punti 10, 11, 14, 15)
- La ricetta si apre sul primo task (`/measure/start/<id>`), non su un elenco. La
lista scende a secondo livello e mostra quali task sono rimasti incompiuti
(`GET /api/measurements/task-progress`, contato per quota e non per tentativo).
- «Fine ciclo misura» è visibile da subito e spento finché mancano quote, con
scritto quante ne mancano.
- Descrizioni con `**grassetto**` e a capo: marcatura, non HTML, così la
sanificazione è per costruzione.
- Una cornice sola per tutte le viste, allineata alla navbar — vedi [`LAYOUT.md`](LAYOUT.md).
- Nessun percorso dell'operatore porta alla statistica, ed è un test.
### Fuori dalla rete (punto 12)
- Alpine, Plotly, PDF.js (+ worker), Fabric e i font sono nell'installazione, con
versione nel nome e impronta SHA-256 verificata da un test.
- Content-Security-Policy a sola origine locale servita **dal client Flask**: prima
esisteva solo sul backend, cioè sulle risposte API e non sulle pagine.
- Tailwind fissato a `3.4.19` nel `Dockerfile.frontend`.
## Migrazioni
| # | Contenuto |
|---|---|
| 001004 | image_path, stazioni, intervallo di misura + auto-logout, `input_duration_ms` |
| 005006 | `production_runs`, misure legate alla produzione |
| 007 | `task_type` dichiarato |
| 008 | tipi di evento del ciclo + `task_id` sull'evento |
| 009 | `requires_lot`, `requires_serial`, `allow_manual_input` |
| 010 | `supervisor_id`, `authorised_at` sulla misura |
Le 008 e 010 usano il *batch mode* per compatibilità SQLite (i test) e sono state
verificate anche in resa MySQL (`alembic upgrade X:Y --sql`) prima del deploy.
La 009 imposta `allow_manual_input = 1` sulle ricette esistenti: conserva il
comportamento in essere invece di irrigidire di colpo ricette già in uso. È una
scelta, e va confermata dal cliente ricetta per ricetta.
## Test status
| | Test | Fail |
|---|---|---|
| Backend (`src/backend/tests/`) | 212 | 0 |
| Frontend (`src/frontend/flask_app/tests/`) | 148 | 0 |
| **Totale** | **360** | **0** |
I quattro fallimenti pre-esistenti tracciati nello snapshot V2.0.0 non ci sono più.
Tre file di test non renderizzano niente e leggono i sorgenti, perché guardano
proprietà che sopravvivono solo se qualcuno le controlla:
| File | Cosa impedisce |
|---|---|
| `test_offline.py` | Una libreria caricata dalla rete, un worker PDF.js lasciato sul CDN, una libreria sostituita senza aggiornare l'impronta |
| `test_layout_shell.py` | Una vista che torna a dichiararsi la propria larghezza |
| `test_template_js_syntax.py` | Una traduzione con l'apostrofo dentro una stringa JS a virgolette singole, che spegne Alpine su tutta la pagina |
## Cosa non è stato provato
Va detto perché non si confonda «i test passano» con «funziona in reparto».
- **Il collaudo sul campo non è mai stato percorso.** Le ricette `COLLAUDO-A` e
`COLLAUDO-B` sono state seminate sul sistema in esercizio (`scripts/seed_collaudo.py`)
insieme a un utente `capoturno`, ma nessuno ha ancora guidato il flusso su un
tablet vero.
- **Il punto 14 non è stato riprodotto su un dispositivo**: è dimostrato che il
layout *poteva* muoversi per quattro motivi e che ora non può più, non che
l'operatore vedesse esattamente quelli.
- **Il punto 12 non è stato provato a rete staccata.** È dimostrato che nessuna
risorsa esterna viene richiesta. Il giro con la rete staccata va fatto, ed è ora il
momento giusto perché la CSP è appena entrata in vigore.
- **Il cicalino** è il suono del browser. Se serva una colonnina luminosa è D-5.
## Stack
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 +
WeasyPrint + Plotly/Kaleido. 12 router.
- **Frontend:** Flask + Jinja2 + Alpine.js 3.15.12 + TailwindCSS 3.4.19 +
Fabric.js 5.3.1 + PDF.js 3.11.174 + Plotly.js 2.32.0 + Flask-Babel —
**tutte copie locali**, nessun CDN.
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt.
- **Tooling:** uv, pytest + pytest-asyncio + httpx + aiosqlite.
## Decisioni architetturali rilevanti
| Decisione | Stato | Note |
|---|---|---|
| Le regole di misura stanno sul server, non sullo schermo | **Confermata (V3.0.0)** | Tracciabilità, inserimento manuale, fuori tolleranza: la schermata può nasconderle, il server le rifiuta. |
| Una app di stazione per PC, dati sul server | **Decisa il 28/07** (D-3) | Validazione con l'IT di Tràfilo aperta come D-4. |
| Descrizioni in marcatura, non in HTML | **Confermata** | Niente HTML accettato in ingresso: la sanificazione è per costruzione, non per filtro. |
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, calibri USB, editor Fabric.js, i18n Babel collaudato. |
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, nessun microservizio. |
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo alto, rotture client. Eventuale API v2. |
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare è breaking per i deploy esistenti. |
## Branch git
- **Corrente:** `V3.0.0`
- **Precedenti:** `V2.0.0`, `V1.0.0``V1.0.7` (release storiche)
@@ -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 `&quot;...&quot;` o riformulazione testuale.
Inoltre **UX rework** della modale assegnazione ricette su `/admin/stations`: dropdown sostituita da layout a 2 colonne (disponibili / assegnate) con bottone inline `+ Assegna`, search filter, empty state esplicativo (mostrava silenziosamente lista vuota se tutte le ricette erano già assegnate).
Test guard aggiunto: `test_template_js_syntax.py` valida ogni inline `<script>` E ogni espressione Alpine (`x-*`, `@*`, `:*`) della pagina con `node --check`. Cattura automaticamente il bug-class apostrofo. Skip se Node non è installato.
## Layout repository (V2.0.0)
```
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`)
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,474 @@
# Integrazione di VisionSuite in TieMeasureFlow — design
> Progetto: TieMeasureFlow V3.0.0 → V3.1.0
> Data: 2026-08-16
> Stato: design approvato, piano di implementazione da scrivere
## Perché
TieMeasureFlow oggi registra misure prese a mano, con il calibro USB o con il
tastierino. Il passo successivo che il cliente chiede è misurare con una camera:
acquisire l'immagine di un pezzo e ricavarne le quote, oppure confrontarne il
profilo con il disegno DXF di riferimento.
La visione artificiale non va scritta qui. Tielogic ha già
[VisionSuite](ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git), un
monorepo di moduli riusabili il cui README indica esplicitamente TieMeasureFlow
fra i consumatori previsti. Questo documento descrive come agganciarlo.
Il terreno è in parte già preparato. La migrazione `007_task_type` ha introdotto
il tipo di task dichiarato e l'enum contiene fin da allora due valori che nessuno
produce né consuma ancora:
```python
TASK_TYPES = ("note", "measure", "drawing", "xf_compare", "camera_measure")
MEASURING_TASK_TYPES = ("measure", "camera_measure")
```
Questo lavoro li riempie.
## Che cosa entra e che cosa resta fuori
**Entra:** l'acquisizione da camera, l'esecuzione di un programma di visione su
quell'immagine — pattern matching più misure, oppure pattern matching più
confronto DXF — e l'ingresso dei risultati nel modello di misura che già esiste.
**Resta fuori, per ora:** i semafori USB. Sono previsti dall'architettura — è
l'agente di stazione a possederli, insieme alle camere — ma non fanno parte di
questa consegna. Il confine è disegnato perché ci entrino senza ristrutturazioni.
## Le otto decisioni
Sono le scelte prese in fase di brainstorming, con il motivo per cui sono state
prese. Chi legge fra sei mesi ha bisogno del motivo più della scelta.
### 1. La visione gira sia sul server sia sulla stazione
Il motore è un componente unico distribuito in due posti: accanto al server e
dentro l'agente di stazione. Questo dà la resilienza — se cade il collegamento,
la stazione continua a misurare — e permette al server di elaborare in fase di
produzione, di comporre ricette per conto del Maker e di rifare analisi su
immagini archiviate.
È coerente con la decisione **D-3**, chiusa il 28/07: dati sul server, app di
stazione su ogni PC.
### 2. Server e stazione montano la stessa identica versione di VisionSuite
Non è una raccomandazione di build: è un vincolo metrologico. Se il server monta
un commit e una stazione ne monta un altro, lo stesso pezzo con lo stesso grafo
può dare due numeri diversi, e nessuno se ne accorge finché non arriva una
contestazione.
Da qui tre obblighi che il design deve garantire:
- VisionSuite entra come **sottomodulo git su un commit preciso**, lo stesso per
entrambi — la forma che il README di VisionSuite prescrive
- ogni misura porta scritto **con quale versione del motore** è stata prodotta
- una stazione che monta una versione diversa da quella del server **si rifiuta
di misurare** e lo dichiara, invece di produrre numeri plausibili e sbagliati
### 3. L'agente di stazione è sottile
L'agente parla ai device, consegna immagini e — più avanti — piloterà i semafori.
Non contiene decisioni di visione.
La conseguenza pratica è l'impacchettamento: un agente che non contiene
VisionSuite non contiene nemmeno PyTorch. Distribuirlo come eseguibile su Windows
o come container su Linux diventa realistico, e la scelta su driver e formato può
restare tardiva — che è bene, perché dipende dalla **D-4**, ancora aperta.
L'agente incorpora il motore solo quando la stazione è configurata per elaborare
in locale, e in quel caso resta comunque un guscio: riceve un grafo, lo esegue,
restituisce le uscite.
### 4. Il server non importa mai VisionSuite
La visione sul lato server vive in un **servizio separato** nello stesso compose,
il *vision worker*. Il server FastAPI continua a fare quello che fa —
autenticazione, ricette, misure, statistica — e resta leggero.
Quattro ragioni concrete: l'immagine dell'API non passa da poche centinaia di
megabyte a cinque gigabyte; un aggiornamento di VisionSuite non richiede di
riavviare l'API in produzione; un'elaborazione che va in crash non porta giù le
richieste degli altri tablet; il giorno in cui la visione chiede una GPU o una
macchina propria si sposta senza toccare il resto.
Il confine va difeso: se un domani il server importasse VisionSuite per una
scorciatoia, il vantaggio si perde in silenzio.
### 5. La configurazione dei device sta sul PC
Le camere — e domani i semafori — appartengono alla macchina, non alla ricetta.
Il server ne tiene uno **specchio** in sola lettura, popolato dall'agente quando
si connette, perché il Maker deve poter scegliere un device da un elenco stando
alla scrivania.
Un device che l'agente non dichiara più è **offline**, non cancellato: una
ricetta che lo referenzia deve continuare a esistere e a spiegare perché non può
girare.
### 6. Dove si esegue lo decide la configurazione della stazione
Ogni PC dichiara se elabora in locale o delega al server, nella stessa
configurazione dove già stanno i device. Il default è il server.
Le macchine capaci si prendono il carico e non mandano immagini in rete; quelle
deboli delegano. Se il server non risponde e la stazione ha il motore, la
stazione ripiega in locale e lo segna nel risultato. Un impianto misto si governa
senza toccare le ricette.
La ricetta non dice mai dove gira: legare una scelta di impianto a un documento
di produzione significa dover rimettere mano alle ricette per spostare il carico.
### 7. Il verdetto lo dà TieMeasureFlow, non il grafo
Gli strumenti di quota di `vs-task` hanno anche loro `nominal`, `plus`, `minus` e
un `outcome`. Quell'esito viene ignorato e quei limiti restano vuoti: il grafo
produce numeri, `measurement_service` decide se sono buoni.
Due giudici sullo stesso valore è il modo più rapido per ottenere un CSV che si
contraddice e una contestazione che non si sa come chiudere.
La conseguenza è che tutto il resto continua a funzionare senza modifiche:
pass/warning/fail, gate del fuori tolleranza con autorizzazione del capoturno,
Cp/Cpk, carta di controllo, colonne del CSV di produzione. Una quota da camera è
una misura come le altre.
### 8. La sorgente immagine è un'astrazione a tre facce
Il motore riceve un'immagine e non sa da dove venga. Le tre sorgenti sono: dal
vivo da un device tramite l'agente, un'immagine di riferimento salvata sul
server, un file caricato dal Maker.
Non è un'astrazione nostra: è quella che VisionSuite si è già data — *«espongono
una sorgente, non una telecamera: è questo che permette di collaudare un impianto
a cinque telecamere senza avere le cinque telecamere sul tavolo, e di scrivere
prove che danno sempre lo stesso risultato»*.
Sblocca tre cose che altrimenti non ci sarebbero:
- il Maker compone alla scrivania, senza tenere occupata una stazione né avere il
pezzo in mano
- una ricetta si prova prima di andare in produzione, invece di scoprirne i
difetti misurando
- diventa possibile una **prova di non regressione**: salvando accanto
all'immagine il risultato atteso, si riesegue l'intero parco ricette dopo un
aggiornamento di VisionSuite e si vede quali quote si sono mosse
Quest'ultima è la rete di sicurezza che rende sostenibile aggiornare il
sottomodulo, con un motore che sta su due host.
> **Non ancora consegnata.** La colonna `expected_json` esiste, ma nessun
> percorso la scrive — nemmeno `preview`, che è l'unico posto dove un grafo gira
> davvero su un'immagine. La prova di non regressione è quindi **promessa da
> questo documento e non mantenuta da nessuna riga di codice**. Diventa una
> consegna nominata del piano 1b: «`preview` può promuovere le proprie uscite a
> `expected_json`». Finché non è fatta, questa decisione non va dichiarata
> completa.
## Architettura
### I componenti
| Componente | Nuovo | Dove vive | Responsabilità |
|---|---|---|---|
| `vendor/visionsuite` | sì | sottomodulo git | i pacchetti `vs-core`, `vs-camera`, `vs-pm2d`, `vs-measure`, `vs-dxf`, su un commit fisso |
| Vision runner | sì | libreria condivisa | immagine + grafo + calibrazione → quote e artefatti. Unica implementazione, due host |
| Vision worker | sì | container nel compose | espone il runner dietro una API interna. Il container pesante |
| Agente di stazione | sì | PC di reparto | possiede i device, acquisisce, apre la connessione verso il server, incorpora il runner se configurato |
| Backend FastAPI | modificato | container esistente | tipi di task, grafo sul task, registro device, inoltro, ingresso risultati. **Nessun import di VisionSuite** |
| Frontend Flask | modificato | container esistente | Maker: device, fotogramma, aggancio quote. Measure: schermata del task con camera |
L'aggancio del sottomodulo segue la forma prescritta dal README di VisionSuite:
dipendenza dai soli pacchetti che servono, dichiarati in path editable.
```toml
[tool.uv.sources]
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true }
```
I due host non montano lo stesso insieme, ed è il punto dell'intera struttura di
VisionSuite:
| Pacchetto | Worker | Agente | Perché |
|---|---|---|---|
| `vs-core` | sì | sì | strutture di base e calibrazione, servono a entrambi |
| `vs-pm2d` | sì | solo se elabora in locale | pattern matching |
| `vs-measure` | sì | solo se elabora in locale | primitive e quote |
| `vs-dxf` | sì | solo se elabora in locale | confronto col disegno. È quello che porta PyTorch |
| `vs-camera` | **no** | sì | il worker non tocca hardware: riceve immagini già acquisite |
Un agente su una stazione che delega al server monta quindi due soli pacchetti,
`vs-core` e `vs-camera`, e resta leggero. È la differenza fra un eseguibile
distribuibile e un'installazione da cinque gigabyte su ogni PC di reparto.
I nomi di importazione non sono uniformi — `vs-core` si importa come
`visionsuite`, `vs-pm2d` come `pm2d`, `vs-dxf` come `dxf_compare` — ed è una
stortura nota e dichiarata a monte, non un errore da correggere qui.
### Il flusso di una misura, esecuzione sul server
```
operatore apre il task
→ client chiede al server di eseguire
→ server chiede all'agente un fotogramma sul device dichiarato
→ agente acquisisce e carica l'immagine
→ server passa immagine e grafo al worker
→ worker restituisce le uscite
→ server applica pass/fail, gate del fuori tolleranza, salva in measurements
→ client mostra esito e immagine con le zone dove ha guardato
```
### Il flusso con esecuzione in locale
Il giro si accorcia: il server manda il grafo all'agente, l'agente acquisisce ed
esegue, e rimanda uscite più immagine. **Da lì in poi il server fa le stesse
identiche cose.** Il verdetto, il gate e il salvataggio non cambiano mai posto:
cambia chi calcola i numeri, non chi decide se sono buoni.
È questo che rende le due strade equivalenti a valle, e che permette di cambiare
la configurazione di una stazione senza toccare né il frontend né le ricette.
## Modello dati
### Il grafo di visione
`recipe_tasks` prende **`vision_json`** (JSON, nullable): il grafo di `vs-task`
serializzato, opaco al server.
Il versionamento arriva gratis. Il copy-on-write delle ricette copia già i task
in profondità, quindi una modifica al grafo su una ricetta con misure produce una
versione nuova come qualunque altra modifica; in `recipe_service` è un campo in
più nella copia, non una logica nuova.
Non si riusa `annotations_json`, che sta lì accanto: le annotazioni sono un
disegno per l'operatore, il grafo è un programma da eseguire. Hanno cicli di vita
e regole di validazione diversi, e mescolarli significa non poter validare né
l'uno né l'altro.
### Il legame fra quota del grafo e quota della ricetta
`recipe_subtasks` prende **`vision_output`** (String, nullable): la coppia
«identificativo dello strumento, nome della sua uscita».
È la stessa forma di legame che `vs-task` usa internamente, e per la stessa
ragione dichiarata nel suo README: *«Il legame è un ID, non un puntatore»* — un
riferimento in memoria non si scrive su disco.
Tutto il resto della subtask — nominale, UTL, UWL, LWL, LTL, unità — resta com'è
e continua a significare quello che significa oggi.
### Le misure
`input_method` passa da `("usb_caliper", "manual")` a
`("usb_caliper", "manual", "camera")`.
Il contorno della visione **non** va su `measurements`. Quella tabella la leggono
la statistica e l'export a ogni giro, e appesantirla la rovina. Ma soprattutto
sarebbe sbagliato di modello: una sola acquisizione produce N quote, e immagine,
overlay, device e versione del motore sono gli stessi per tutte.
Nasce quindi **`vision_results`**, una riga per **esecuzione**:
| Campo | Perché c'è |
|---|---|
| `image_path`, `overlay_path` | l'immagine, e quella con sopra le zone dove ha guardato |
| `engine_version` | il commit di VisionSuite che ha prodotto i numeri |
| `executed_on` | `server` o `station` |
| `station_id`, `device_id` | da quale macchina e quale camera |
| `calibration_snapshot` | la taratura in vigore in quel momento, **copiata** |
| `graph_snapshot` | il grafo effettivamente eseguito, **copiato** |
| `duration_ms`, `executed_at` | quanto è costata, quando |
Su `measurements` una sola colonna nuova: `vision_result_id`, FK nullable.
Calibrazione e grafo si copiano invece di essere referenziati per la stessa
ragione: una taratura rifatta sei mesi dopo non deve riscrivere il significato di
una misura già presa.
### Il registro dei device
**`station_devices`**: `station_id`, `code` (l'identificativo locale), `kind`
(`camera` oggi, `light` domani), `label`, `capabilities` (JSON), `calibration`
(JSON) con `calibration_taken_at`, `last_seen_at`.
Si popola dall'agente. Il server lo riceve e non lo modifica.
### Le immagini di riferimento
**`vision_reference_images`**: `task_id`, `path`, e la provenienza per intero —
`station_id`, `device_id`, snapshot della calibrazione, `engine_version`,
`acquired_at`, una nota — più `expected_json` per l'atteso della prova di non
regressione.
La provenienza non è rimandabile. Senza, si riesegue il grafo mesi dopo, escono
numeri diversi, e non c'è modo di distinguere un motore aggiornato da un'ottica
spostata.
### Migrazione 011
Cinque cose: `vision_json` su `recipe_tasks`, `vision_output` su
`recipe_subtasks`, `camera` in `input_method_enum`, `vision_result_id` su
`measurements`, e le tre tabelle nuove.
Più una sesta, **da fare adesso o mai**: `task_type_enum` contiene `xf_compare`,
refuso per `dxf_compare`. Oggi nessuna riga usa quel valore e correggerlo costa
una riga. Dal primo task salvato in poi diventa una migrazione di dati, e il
refuso finisce nelle API pubbliche.
Come le migrazioni 008 e 010, va scritta in *batch mode* per la compatibilità
SQLite dei test e verificata in resa MySQL con `alembic upgrade X:Y --sql` prima
del deploy: è la procedura che il progetto si è già dato.
## API
L'agente apre lui la connessione. **Il server non bussa mai a un PC**, e questo
evita di dover aprire porte sulle macchine di reparto — che è materia della D-4.
| Verso | Endpoint | Cosa fa |
|---|---|---|
| agente → server | `WS /api/stations/agent` | si autentica con la chiave di stazione, dichiara i device, resta in ascolto |
| server → agente | comando `acquire` | «scatta sul device X»; l'agente carica con `POST /api/vision/frames` |
| server → agente | comando `execute` | esecuzione in locale: manda il grafo, riceve le uscite |
| client → server | `POST /api/vision/execute` | esegui il task per la produzione in corso; **il server sceglie dove** |
| client → server | `POST /api/vision/preview` | authoring: esegui su una sorgente qualsiasi, non salvare nulla |
| client → server | `GET /api/stations/{code}/devices` | l'elenco per il Maker |
| client → server | `POST /api/vision/reference-images` | salva un fotogramma come riferimento |
| server → worker | `POST /run` | interno, mai esposto: immagine e grafo → uscite e overlay |
Un solo endpoint decide dove si esegue, ed è `execute`. Il client non sa e non
deve sapere se ha misurato il server o la stazione: chiede una misura e riceve un
esito.
## Errori e casi limite
| Caso | Comportamento |
|---|---|
| Versione del motore diversa fra stazione e server | la stazione si rifiuta di misurare e lo dichiara. Mai produrre numeri con un motore non concorde |
| Agente non raggiungibile | il task di visione non è eseguibile e lo dice. Da decidere con il cliente se la stazione debba consentire il ripiego sul calibro |
| Server non raggiungibile, stazione con motore | la stazione esegue in locale e lo segna in `executed_on` |
| Device dichiarato dalla ricetta ma offline | la ricetta resta valida, il task spiega perché non può girare |
| Il grafo non produce un'uscita attesa da una subtask | la misura non si salva, l'errore nomina l'uscita mancante |
| Elaborazione in crash o oltre il tempo | il worker isola il guasto, l'API resta in piedi, il task riporta l'errore |
| Device senza calibrazione | le quote in millimetri non si producono. Mai convertire con un fattore implicito |
## Prove
Ai tre file di test che il progetto ha già e che leggono i sorgenti invece di
renderizzare — `test_offline.py`, `test_layout_shell.py`,
`test_template_js_syntax.py` — se ne aggiunge la stessa specie:
- **una prova che il server non importi VisionSuite.** È il confine su cui poggia
la decisione 4, e senza qualcuno che lo controlli si perde in silenzio
- **una prova che il commit del sottomodulo dichiarato coincida** con quello che
server e agente montano
Per il resto, il runner si prova su immagini di riferimento salvate — che è
precisamente ciò per cui l'astrazione della sorgente esiste. Nessuna prova
richiede una camera collegata.
## Quello che questo design non risolve
- **Il dimensionamento del server.** Il pattern matching è pesante e, con
l'esecuzione lato server come default, la CPU è contesa fra tutte le stazioni.
Va aggiunto alla **D-4**, che finora chiedeva solo quale macchina e quanto
disco: ora chiede anche quanti core e quanta RAM.
- **La conservazione delle immagini.** Due politiche distinte, entrambe da
portare in D-4: le immagini di riferimento sono poche e vivono quanto la
ricetta; quelle di produzione sono una per pezzo e crescono senza limite, e
vogliono una scadenza o una regola del tipo «si conservano solo quelle dei
pezzi fuori tolleranza».
- **Che cosa fa la stazione se la rete cade** e non è configurata per elaborare
in locale: si blocca, o consente il calibro e segna il task come non
eseguibile? È una domanda per il cliente.
- **L'impacchettamento dell'agente.** Le macchine possono essere Linux o Windows
e i driver possono stare sulla macchina o nel container. La decisione è
volutamente tardiva; il design la rende tale tenendo l'agente sottile e neutro.
## Correzioni imposte dall'esecuzione
Il piano derivato da questo documento è stato eseguito il 16/08/2026. Sei cose
che qui erano scritte male o taciute, corrette dai fatti.
### Il gate del fuori tolleranza vale per l'acquisizione, non per la quota
La decisione 7 e il testo del punto 5 parlano di «la quota successiva». Quella
regola era stata pensata per la misura **manuale e sequenziale**, dove esiste
davvero un momento successivo in cui l'operatore potrebbe tirare avanti con una
lettura cattiva irrisolta.
Una camera produce tutte le N quote **nello stesso istante**. Dentro
un'acquisizione non esiste un «tirare avanti», quindi lì non c'è nulla da
presidiare — e applicare la regola sequenziale a un evento simultaneo produceva
un blocco permanente: la lettura fuori tolleranza non veniva mai registrata,
quindi nessuno poteva autorizzarla, e rifare la stessa foto ripeteva l'errore.
Il gate si valuta **una volta per acquisizione**: se la misura pendente è fra le
quote che questa acquisizione riprodurrà non blocca — riacquisire *è* rimisurare
quelle quote, che questo documento già dichiara la via d'uscita che non richiede
il capoturno. Fra due acquisizioni, e alla chiusura del ciclo, blocca come prima.
### Il repository è deliberatamente bi-ambiente
`vs-task`, `vs-measure` e `vs-pm2d` dichiarano `requires-python >= 3.13`;
`vs-core` si ferma a 3.10. Backend e frontend restano su **3.11**, l'albero della
visione gira su **3.13**, e l'extra `vision` porta i marker d'ambiente che
tengono separate le due risoluzioni.
Questo rafforza la decisione 4 più di come era argomentata: con VisionSuite dentro
il server FastAPI, questa scoperta avrebbe costretto l'intero backend a 3.13.
### La versione del motore si timbra al build
`vendor/visionsuite/.git` è un file che rimanda a `../../.git/modules/...`, che
nel contesto di build non esiste: interrogare git dentro il container non
funziona. `engine_version()` legge `VISION_ENGINE_VERSION` dall'ambiente, ripiega
su git nei checkout di sviluppo, e **alza un errore** se non risolve né l'uno né
l'altro. Il build fallisce se la variabile manca.
### La divisione degli extra compra esplicitezza, non peso
Gli extra sono due — `vision` per il runner, `vision-worker` per il servizio web
— ed è la forma giusta. Ma la motivazione che sembrava ovvia è falsa: `vs-pm2d`
dichiara **di suo** `fastapi`, `uvicorn`, `python-multipart` e `pillow`, quindi
un agente di stazione che fa pattern matching se li porterà dietro comunque. La
correzione vera è a monte — una libreria non dovrebbe dipendere da un server web
— e va aggiunta alle verifiche su VisionSuite qui sotto.
### L'immagine di produzione non viene ancora conservata
`vision_results.image_path` e `overlay_path` migrano e restano `NULL`: nulla le
scrive. Il flusso descritto in questo documento si chiude con «il client mostra
esito e immagine», e finché quelle colonne sono vuote una misura contestata non
ha prova materiale. Consegna nominata del piano 1b, insieme all'overlay.
### Prima di applicare le migrazioni in produzione
La 011 rinomina un valore di enum su una tabella in esercizio. Va contato prima
che nessuna riga usi `xf_compare`: in strict mode una riga sopravvissuta fa
fallire l'`ALTER` a metà deploy, fuori strict mode diventa `''` in silenzio.
Attenzione anche alla finestra: il `MODIFY` su `recipe_tasks` è una rinomina di
valore e userà `ALGORITHM=COPY`, mentre quello su `measurements.input_method`
aggiunge in coda e dovrebbe restare `INPLACE` — da confermare sulle dimensioni
reali, perché `measurements` è la tabella grossa.
## Da verificare prima di cominciare
VisionSuite è stato lavorato fino al 15/08/2026 e il suo ultimo commit è
*«Rassegna delle firme pubbliche prima che si congelino»*: le API si stanno
stabilizzando proprio ora. Prima di fissare il commit del sottomodulo:
- **il suo README è indietro rispetto al codice.** Dichiara `vs-measure` «da
scrivere» quando invece esiste, con circa 4.865 righe e tredici file di test, e
dichiara `vs-dxf` non avviabile quando l'import di `common.calibration` non c'è
più. Va riletto lo stato reale, non quello documentato.
- **il test di calibrazione dato per rosso** — la focale recuperata a 373 contro
456 attesi, con tolleranza al 5% — va verificato sullo stato attuale. Il README
di VisionSuite avverte di guardarlo *prima* di costruirci sopra misure in
millimetri, che è esattamente ciò che questo lavoro fa. Le calibrazioni sono
state toccate il 13/08 e lo stato potrebbe essere cambiato.
- **il peso dell'ambiente.** `vs-dxf` dipende da PyTorch e l'installazione
completa arriva a circa 5 GB. È la ragione per cui il worker è un container a
parte, e va confermato che il solo sottoinsieme necessario pesi meno.
+107
View File
@@ -0,0 +1,107 @@
[project]
name = "tiemeasureflow"
version = "2.0.0"
description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations."
requires-python = ">=3.11"
authors = [
{ name = "Adriano Dal Pastro", email = "adrianodalpastro@tielogic.com" },
]
# Shared core deps used by both backend and the Flask frontend.
dependencies = [
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
# Backend (FastAPI + DB + reports).
server = [
"fastapi>=0.110.0",
"uvicorn[standard]>=0.30.0",
"sqlalchemy[asyncio]>=2.0.0",
"asyncmy>=0.2.0",
"alembic>=1.13.0",
"bcrypt>=4.0.0",
"pillow>=10.0.0",
"python-multipart>=0.0.6",
"jinja2>=3.1.0",
"plotly>=5.0.0",
"kaleido>=0.2.0",
"weasyprint>=62.0",
"pdfplumber>=0.10.0",
"httpx>=0.27.0",
]
# Frontend (Flask tablet UI).
client = [
"flask>=3.0.0",
"flask-babel>=4.0.0",
"flask-wtf>=1.2.0",
"requests>=2.31.0",
"urllib3>=2.0.0",
"gunicorn>=21.0.0",
]
# Dev / test (covers both server and client tests).
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
"aiosqlite>=0.20.0",
"coverage>=7.0.0",
]
# Vision runner (VisionSuite submodule): what src/vision/runner.py needs to
# execute a graph. vs-task/vs-measure/vs-pm2d require Python >=3.13 upstream;
# marker keeps the base 3.11 resolution untouched. numpy is declared
# explicitly - runner.py imports it directly, and it must not depend on
# being pulled in as a side effect of vs-pm2d's own dependencies.
vision = [
"visionsuite; python_version>='3.13'",
"vs-task; python_version>='3.13'",
"vs-measure; python_version>='3.13'",
"vs-pm2d; python_version>='3.13'",
"numpy; python_version>='3.13'",
]
# Vision worker (src/vision_worker/): the FastAPI shell around the runner,
# on top of `vision`. Kept separate on purpose (Ruling R9) - VisionSuite's
# whole structure exists so a consumer installs only what it needs, and a
# later station agent embeds the same runner without being a web service.
# It must not drag fastapi/uvicorn onto shop-floor PCs.
vision-worker = [
"tiemeasureflow[vision]",
"fastapi>=0.110.0; python_version>='3.13'",
"uvicorn[standard]>=0.30.0; python_version>='3.13'",
"pillow>=10.0.0; python_version>='3.13'",
"python-multipart>=0.0.6; python_version>='3.13'",
]
[project.scripts]
# Backend
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
# Source layout will be src/backend/ and src/frontend/flask_app/ after the
# folder restructure (Phase 2 of the V2.0.0 plan).
packages = ["src/backend", "src/frontend"]
[tool.uv]
# Pin the resolver to the deps we declared; reproducible builds.
package = false
[tool.uv.sources]
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests", "src/vision/tests", "src/vision_worker/tests"]
+321
View File
@@ -0,0 +1,321 @@
"""Seed the recipes used to accept points 2, 3, 5, 8, 9 and 11 on a real screen.
The tests prove the rules hold; this puts them in front of an operator. Two
recipes, because they need opposite settings:
COLLAUDO-A typing allowed, lot compulsory, two measurement tasks with
documental tasks before, between and after them, interval of two
minutes. This is the one to drive: the measurement loop, the
bidirectional countdown, the out-of-tolerance gate, the cycle that
closes only on the last measurement task.
COLLAUDO-B caliper only, lot and serial compulsory. This one is to look at:
the keypad is not drawn, and Avvia does not start until both
traceability fields are filled in.
Run it inside the server container, where src.backend and the database settings
are already in place:
docker compose cp scripts/seed_collaudo.py server:/tmp/seed_collaudo.py
docker compose exec server uv run python /tmp/seed_collaudo.py --station ST-DEFAULT
Re-running finds the recipes already there and stops. --replace deletes them
first, along with anything measured against them - which is what you want between
one acceptance session and the next, and never what you want by accident.
"""
import argparse
import asyncio
import sys
from sqlalchemy import delete, select
sys.path.insert(0, "/app")
from src.backend.database import async_session_factory # noqa: E402
from src.backend.models.orm.measurement import Measurement # noqa: E402
from src.backend.models.orm.production import ( # noqa: E402
ProductionEvent, ProductionRun,
)
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
from src.backend.models.orm.station import ( # noqa: E402
Station, StationRecipeAssignment,
)
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
from src.backend.models.orm.user import User # noqa: E402
from src.backend.services import auth_service # noqa: E402
CODES = ("COLLAUDO-A", "COLLAUDO-B")
# Limits chosen so every outcome is one deliberate keystroke away:
# 10.00 -> conforme 10.30 -> attenzione (dentro tolleranza)
# 12.00 -> fuori tolleranza, serve il capoturno
QUOTES_A1 = [
dict(marker_number=1, description="Diametro esterno",
nominal=10.0, ltl=9.5, lwl=9.8, uwl=10.2, utl=10.5, unit="mm"),
dict(marker_number=2, description="Spessore parete",
nominal=25.0, ltl=24.5, lwl=24.8, uwl=25.2, utl=25.5, unit="mm"),
]
QUOTES_A2 = [
dict(marker_number=3, description="Lunghezza totale",
nominal=100.0, ltl=99.0, lwl=99.5, uwl=100.5, utl=101.0, unit="mm"),
]
QUOTES_B = [
dict(marker_number=1, description="Diametro con calibro",
nominal=10.0, ltl=9.5, lwl=9.8, uwl=10.2, utl=10.5, unit="mm"),
]
# A description with a blank line and a bold phrase: point 11 is visible or it is
# not, and this is where you look.
NOTA_INIZIALE = (
"Prendere il pezzo dalla cassetta **a sinistra** della postazione.\n"
"Pulire la superficie prima di misurare.\n\n"
"**Attenzione**: non misurare pezzi ancora caldi."
)
NOTA_INTERMEDIA = (
"Girare il pezzo di **90 gradi** prima della seconda misura.\n"
"Se il pezzo non appoggia in piano, ripetere il posizionamento."
)
NOTA_FINALE = (
"Riporre il pezzo nella cassetta **a destra**.\n"
"Segnalare al capoturno qualsiasi quota fuori tolleranza."
)
async def _find_recipes(session, codes):
result = await session.execute(select(Recipe).where(Recipe.code.in_(codes)))
return list(result.scalars().all())
async def _delete_recipes(session, recipes):
"""Remove the collaudo recipes and everything measured against them."""
for recipe in recipes:
versions = (await session.execute(
select(RecipeVersion.id).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().all()
runs = (await session.execute(
select(ProductionRun.id).where(ProductionRun.recipe_id == recipe.id)
)).scalars().all()
if versions:
await session.execute(
delete(Measurement).where(Measurement.version_id.in_(versions))
)
if runs:
await session.execute(
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
)
await session.execute(
delete(ProductionRun).where(ProductionRun.id.in_(runs))
)
await session.execute(
delete(StationRecipeAssignment).where(
StationRecipeAssignment.recipe_id == recipe.id
)
)
# Versions, tasks and subtasks go with the recipe through the ORM cascade.
await session.delete(recipe)
await session.flush()
async def _add_task(session, version_id, order_index, title, task_type,
directive=None, description=None, quotes=()):
task = RecipeTask(
version_id=version_id,
order_index=order_index,
title=title,
task_type=task_type,
directive=directive,
description=description,
)
session.add(task)
await session.flush()
for quote in quotes:
session.add(RecipeSubtask(task_id=task.id, **quote))
await session.flush()
return task
async def _create_recipe(session, user_id, *, code, name, description,
interval, requires_lot, requires_serial,
allow_manual_input):
recipe = Recipe(
code=code,
name=name,
description=description,
created_by=user_id,
active=True,
measurement_interval_minutes=interval,
requires_lot=requires_lot,
requires_serial=requires_serial,
allow_manual_input=allow_manual_input,
)
session.add(recipe)
await session.flush()
version = RecipeVersion(
recipe_id=recipe.id,
version_number=1,
is_current=True,
created_by=user_id,
change_notes="Ricetta di collaudo",
)
session.add(version)
await session.flush()
return recipe, version
async def _assign(session, station, recipe, user_id):
session.add(StationRecipeAssignment(
station_id=station.id, recipe_id=recipe.id, assigned_by=user_id,
))
await session.flush()
async def _ensure_supervisor(session, username, password):
"""A capoturno with the role, not an admin standing in for one.
An admin is accepted by the authorisation check, but the acceptance session
should exercise the role the shop floor will actually use.
"""
existing = (await session.execute(
select(User).where(User.username == username)
)).scalar_one_or_none()
if existing is not None:
return existing, False
user = await auth_service.create_user(
session,
username=username,
password=password,
display_name="Capo Turno (collaudo)",
roles=["Supervisor", "MeasurementTec"],
)
await session.flush()
return user, True
async def seed(station_code: str, replace: bool, supervisor_password: str) -> int:
async with async_session_factory() as session:
owner = (await session.execute(
select(User).where(User.is_admin == True) # noqa: E712
)).scalars().first()
if owner is None:
print("Nessun utente amministratore: impossibile intestare le ricette.")
return 2
station = (await session.execute(
select(Station).where(Station.code == station_code)
)).scalar_one_or_none()
if station is None:
print(f"Stazione '{station_code}' inesistente.")
return 2
existing = await _find_recipes(session, CODES)
if existing and not replace:
found = ", ".join(r.code for r in existing)
print(f"Ricette di collaudo gia' presenti ({found}).")
print("Usa --replace per rifarle da zero (cancella anche le misure).")
return 1
if existing:
await _delete_recipes(session, existing)
print(f"Rimosse: {', '.join(r.code for r in existing)}")
# ---- COLLAUDO-A: the one to drive ----
recipe_a, version_a = await _create_recipe(
session, owner.id,
code="COLLAUDO-A",
name="Collaudo — ciclo di misura",
description=(
"Due task di misura con task documentali intorno. "
"Lotto obbligatorio, inserimento manuale consentito, "
"intervallo di 2 minuti."
),
interval=2,
requires_lot=True,
requires_serial=False,
allow_manual_input=True,
)
await _add_task(
session, version_a.id, 0, "Preparazione del pezzo", "note",
directive="Leggere prima di iniziare",
description=NOTA_INIZIALE,
)
await _add_task(
session, version_a.id, 1, "Misura 1 — diametro e spessore", "measure",
directive="Misurare le due quote in sequenza",
description="Quota **1** e quota **2**.\nUsare il calibro o il tastierino.",
quotes=QUOTES_A1,
)
await _add_task(
session, version_a.id, 2, "Riposizionamento", "note",
directive="Fra la prima e la seconda misura",
description=NOTA_INTERMEDIA,
)
await _add_task(
session, version_a.id, 3, "Misura 2 — lunghezza", "measure",
directive="Ultimo task di misura: chiude il ciclo",
description="Alla conferma di questa quota **riparte l'intervallo**.",
quotes=QUOTES_A2,
)
await _add_task(
session, version_a.id, 4, "Chiusura", "note",
directive="Dopo le misure",
description=NOTA_FINALE,
)
await _assign(session, station, recipe_a, owner.id)
# ---- COLLAUDO-B: the one to look at ----
recipe_b, version_b = await _create_recipe(
session, owner.id,
code="COLLAUDO-B",
name="Collaudo — solo calibro",
description=(
"Lotto e seriale obbligatori, inserimento manuale vietato: "
"il tastierino non compare e Avvia non parte finche' mancano i dati."
),
interval=5,
requires_lot=True,
requires_serial=True,
allow_manual_input=False,
)
await _add_task(
session, version_b.id, 0, "Misura con calibro", "measure",
directive="Il valore deve arrivare dal calibro",
description="Nessun tastierino: la ricetta **non ammette valori digitati**.",
quotes=QUOTES_B,
)
await _assign(session, station, recipe_b, owner.id)
supervisor, created = await _ensure_supervisor(
session, "capoturno", supervisor_password,
)
await session.commit()
print(f"Ricette create e assegnate alla stazione {station_code}:")
print(" COLLAUDO-A ciclo di misura, lotto obbligatorio, intervallo 2 min")
print(" COLLAUDO-B solo calibro, lotto e seriale obbligatori")
if created:
print(f"Capoturno creato: {supervisor.username} / {supervisor_password}")
print(" cambiare la password prima di qualunque uso reale.")
else:
print(f"Capoturno gia' presente: {supervisor.username} (password invariata)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--station", default="ST-DEFAULT",
help="codice della stazione a cui assegnare le ricette")
parser.add_argument("--replace", action="store_true",
help="cancella le ricette di collaudo esistenti e le misure fatte su di esse")
parser.add_argument("--supervisor-password", default="Collaudo2026!",
help="password del capoturno, se va creato")
args = parser.parse_args()
return asyncio.run(
seed(args.station, args.replace, args.supervisor_password)
)
if __name__ == "__main__":
raise SystemExit(main())
-26
View File
@@ -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 2"]
-19
View File
@@ -1,19 +0,0 @@
"""SQLAlchemy models for TieMeasureFlow."""
from models.user import User
from models.recipe import Recipe, RecipeVersion
from models.task import RecipeTask, RecipeSubtask
from models.measurement import Measurement
from models.access_log import AccessLog
from models.setting import SystemSetting, RecipeVersionAudit
__all__ = [
"User",
"Recipe",
"RecipeVersion",
"RecipeTask",
"RecipeSubtask",
"Measurement",
"AccessLog",
"SystemSetting",
"RecipeVersionAudit",
]
-5
View File
@@ -1,5 +0,0 @@
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_functions = test_*
-77
View File
@@ -1,77 +0,0 @@
"""Measurement service - pass/fail calculation, data storage."""
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.measurement import Measurement
from models.task import RecipeSubtask
def calculate_pass_fail(
value: float, subtask: RecipeSubtask
) -> tuple[str, float | None]:
"""Calculate pass/fail status and deviation based on tolerances.
Returns (status, deviation) where status is 'pass', 'warning', or 'fail'.
Logic:
- If value is outside UTL/LTL -> 'fail'
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
- Otherwise -> 'pass'
"""
deviation = None
if subtask.nominal is not None:
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
# Check fail (outside tolerance limits)
if subtask.utl is not None and value > float(subtask.utl):
return "fail", deviation
if subtask.ltl is not None and value < float(subtask.ltl):
return "fail", deviation
# Check warning (outside warning limits)
if subtask.uwl is not None and value > float(subtask.uwl):
return "warning", deviation
if subtask.lwl is not None and value < float(subtask.lwl):
return "warning", deviation
return "pass", deviation
async def save_measurement(
db: AsyncSession,
subtask_id: int,
version_id: int,
measured_by: int,
value: float,
lot_number: str | None = None,
serial_number: str | None = None,
input_method: str = "manual",
) -> Measurement:
"""Save a single measurement with auto-calculated pass/fail."""
# Get subtask for tolerance values
result = await db.execute(
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
)
subtask = result.scalar_one_or_none()
if subtask is None:
raise ValueError(f"Subtask {subtask_id} not found")
pass_fail, deviation = calculate_pass_fail(value, subtask)
measurement = Measurement(
subtask_id=subtask_id,
version_id=version_id,
measured_by=measured_by,
value=value,
pass_fail=pass_fail,
deviation=deviation,
lot_number=lot_number,
serial_number=serial_number,
input_method=input_method,
)
db.add(measurement)
await db.flush()
await db.refresh(measurement)
return measurement
@@ -1,16 +1,17 @@
"""FastAPI middleware for TieMeasureFlow.""" """FastAPI middleware for TieMeasureFlow."""
from middleware.api_key import ( from src.backend.api.middleware.api_key import (
get_current_user, get_current_user,
require_role, require_role,
require_admin, require_admin,
require_maker, require_maker,
require_measurement_tec, require_measurement_tec,
require_metrologist, require_metrologist,
require_supervisor,
require_admin_user, require_admin_user,
) )
from middleware.logging import AccessLogMiddleware from src.backend.api.middleware.logging import AccessLogMiddleware
from middleware.rate_limit import RateLimitMiddleware from src.backend.api.middleware.rate_limit import RateLimitMiddleware
from middleware.security_headers import SecurityHeadersMiddleware from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
__all__ = [ __all__ = [
"get_current_user", "get_current_user",
@@ -19,6 +20,7 @@ __all__ = [
"require_maker", "require_maker",
"require_measurement_tec", "require_measurement_tec",
"require_metrologist", "require_metrologist",
"require_supervisor",
"require_admin_user", "require_admin_user",
"AccessLogMiddleware", "AccessLogMiddleware",
"RateLimitMiddleware", "RateLimitMiddleware",
@@ -3,8 +3,8 @@ from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from models.user import User from src.backend.models.orm.user import User
async def get_current_user( async def get_current_user(
@@ -68,4 +68,5 @@ def require_admin():
require_maker = require_role("Maker") require_maker = require_role("Maker")
require_measurement_tec = require_role("MeasurementTec") require_measurement_tec = require_role("MeasurementTec")
require_metrologist = require_role("Metrologist") require_metrologist = require_role("Metrologist")
require_supervisor = require_role("Supervisor")
require_admin_user = require_admin() require_admin_user = require_admin()
@@ -6,8 +6,8 @@ from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from sqlalchemy import insert from sqlalchemy import insert
from database import async_session_factory from src.backend.database import async_session_factory
from models.access_log import AccessLog from src.backend.models.orm.access_log import AccessLog
class AccessLogMiddleware(BaseHTTPMiddleware): class AccessLogMiddleware(BaseHTTPMiddleware):
@@ -11,7 +11,7 @@ from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from config import settings from src.backend.config import settings
class RateLimitMiddleware(BaseHTTPMiddleware): class RateLimitMiddleware(BaseHTTPMiddleware):
@@ -32,6 +32,25 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self._general_requests: dict[str, list[float]] = defaultdict(list) self._general_requests: dict[str, list[float]] = defaultdict(list)
self._request_count = 0 # Counter for triggering eviction self._request_count = 0 # Counter for triggering eviction
@staticmethod
def _client_ip(request: Request) -> str:
"""Resolve the originating client IP, honoring proxy headers.
Order of precedence: ``X-Forwarded-For`` (first hop), ``X-Real-IP``,
``request.client.host``. Required because Nginx and the Flask client
sit between the tablet and the API; without parsing these headers
every tablet shares one bucket.
"""
xff = request.headers.get("x-forwarded-for")
if xff:
first = xff.split(",")[0].strip()
if first:
return first
real = request.headers.get("x-real-ip")
if real:
return real.strip()
return request.client.host if request.client else "unknown"
def _clean_window(self, timestamps: list[float], now: float) -> list[float]: def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
"""Remove timestamps outside the current sliding window.""" """Remove timestamps outside the current sliding window."""
cutoff = now - self.WINDOW_SECONDS cutoff = now - self.WINDOW_SECONDS
@@ -68,7 +87,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
return True, 0 return True, 0
async def dispatch(self, request: Request, call_next: Callable) -> Response: async def dispatch(self, request: Request, call_next: Callable) -> Response:
client_ip = request.client.host if request.client else "unknown" client_ip = self._client_ip(request)
now = time.time() now = time.time()
path = request.url.path path = request.url.path
@@ -8,18 +8,20 @@ from typing import Callable
from fastapi import Request, Response from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from config import settings from src.backend.config import settings
# Content Security Policy - allows CDN resources used by the client # Content Security Policy - same-origin only: every third-party library now ships with
# the app (see frontend static/vendor/), so no CDN host needs allowing. The install runs
# on an isolated shop-floor network where an outbound fetch would simply fail.
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts # Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
CSP = ( CSP = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' " "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://cdn.plot.ly; " "style-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " "font-src 'self'; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data: blob:; " "img-src 'self' data: blob:; "
"connect-src 'self'" "connect-src 'self'; "
"worker-src 'self' blob:"
) )
@@ -2,16 +2,16 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import get_current_user from src.backend.api.middleware.api_key import get_current_user
from models.user import User from src.backend.models.orm.user import User
from schemas.user import ( from src.backend.models.api.user import (
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
UserProfileUpdate, UserProfileUpdate,
UserResponse, UserResponse,
) )
from services.auth_service import authenticate_user, login_user, logout_user from src.backend.services.auth_service import authenticate_user, login_user, logout_user
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -1,5 +1,5 @@
"""File upload/download/delete router for images and PDFs.""" """File upload/download/delete router for images and PDFs."""
import os import logging
import re import re
from pathlib import Path from pathlib import Path
@@ -7,9 +7,11 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from PIL import Image from PIL import Image
from config import settings from src.backend.config import settings
from middleware.api_key import get_current_user, require_maker from src.backend.api.middleware.api_key import get_current_user, require_maker
from models.user import User from src.backend.models.orm.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/files", tags=["files"]) router = APIRouter(prefix="/api/files", tags=["files"])
@@ -65,6 +67,37 @@ def sanitize_filename(filename: str) -> str:
return filename return filename
def resolve_upload_path(file_path: str) -> Path:
"""Resolve a user-supplied relative path inside the uploads directory.
Raises:
HTTPException: 404 if the path escapes the uploads directory,
cannot be resolved, or does not point to an existing file.
"""
try:
full_path = (settings.upload_path / file_path).resolve()
if not full_path.is_relative_to(settings.upload_path.resolve()):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
except HTTPException:
raise
except (OSError, RuntimeError, ValueError):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
if not full_path.exists() or not full_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
return full_path
def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)): def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)):
"""Generate a thumbnail for an image.""" """Generate a thumbnail for an image."""
try: try:
@@ -73,7 +106,7 @@ def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[i
img.save(thumbnail_path, quality=85) img.save(thumbnail_path, quality=85)
except Exception as e: except Exception as e:
# If thumbnail generation fails, we continue without it # If thumbnail generation fails, we continue without it
print(f"Thumbnail generation failed: {e}") logger.warning("Thumbnail generation failed for %s: %s", image_path, e)
@router.post("/upload") @router.post("/upload")
@@ -164,30 +197,7 @@ async def get_file(
Requires authentication but no specific role. Requires authentication but no specific role.
""" """
# Construct full path full_path = resolve_upload_path(file_path)
full_path = settings.upload_path / file_path
# Security: ensure path is within uploads directory
try:
full_path = full_path.resolve()
if not str(full_path).startswith(str(settings.upload_path.resolve())):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
except Exception:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
# Check if file exists
if not full_path.exists() or not full_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
return FileResponse(full_path) return FileResponse(full_path)
@@ -200,29 +210,7 @@ async def delete_file(
Also deletes associated thumbnail if it exists. Also deletes associated thumbnail if it exists.
""" """
# Construct full path full_path = resolve_upload_path(file_path)
full_path = settings.upload_path / file_path
# Security: ensure path is within uploads directory
try:
full_path = full_path.resolve()
if not str(full_path).startswith(str(settings.upload_path.resolve())):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
except Exception:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
# Check if file exists
if not full_path.exists() or not full_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
# Delete thumbnail if it exists # Delete thumbnail if it exists
if full_path.parent.name != "thumbnails": if full_path.parent.name != "thumbnails":
@@ -8,23 +8,26 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import and_, func, select from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import ( from src.backend.api.middleware.api_key import (
get_current_user, get_current_user,
require_measurement_tec, require_measurement_tec,
require_metrologist, require_metrologist,
) )
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from models.recipe import RecipeVersion from src.backend.models.orm.recipe import RecipeVersion
from models.setting import SystemSetting from src.backend.models.orm.setting import SystemSetting
from models.user import User from src.backend.models.orm.user import User
from schemas.measurement import ( from src.backend.models.api.measurement import (
MeasurementAuthorisation,
MeasurementBatchCreate, MeasurementBatchCreate,
MeasurementCreate, MeasurementCreate,
MeasurementListResponse, MeasurementListResponse,
MeasurementResponse, MeasurementResponse,
TaskProgressListResponse,
) )
from services.measurement_service import save_measurement from src.backend.services import auth_service, measurement_service
from src.backend.services.measurement_service import save_measurement
router = APIRouter(prefix="/api/measurements", tags=["measurements"]) router = APIRouter(prefix="/api/measurements", tags=["measurements"])
@@ -46,6 +49,8 @@ async def create_measurement(
lot_number=data.lot_number, lot_number=data.lot_number,
serial_number=data.serial_number, serial_number=data.serial_number,
input_method=data.input_method, input_method=data.input_method,
input_duration_ms=data.input_duration_ms,
production_run_id=data.production_run_id,
) )
return MeasurementResponse.model_validate(measurement) return MeasurementResponse.model_validate(measurement)
except ValueError as e: except ValueError as e:
@@ -55,6 +60,78 @@ async def create_measurement(
) )
@router.get("/pending-authorisation", response_model=MeasurementResponse | None)
async def get_pending_authorisation(
version_id: int = Query(..., gt=0),
production_run_id: int | None = Query(None, gt=0),
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""The out-of-tolerance measurement holding this operator up, or null.
Asked on load, so that reloading the page is not a way past the gate: the
screen finds the same block waiting for it that the server enforces.
"""
blocking = await measurement_service.pending_authorisation(
db, version_id, user.id, production_run_id,
)
return MeasurementResponse.model_validate(blocking) if blocking else None
@router.get("/task-progress", response_model=TaskProgressListResponse)
async def get_task_progress(
version_id: int = Query(..., gt=0),
production_run_id: int | None = Query(None, gt=0),
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""How many quotes of each measurement task are already taken.
The task list is where an operator picks up work they left behind, and until
now it showed a task begun and a task never touched identically.
"""
tasks = await measurement_service.task_progress(
db, version_id, user.id, production_run_id,
)
return TaskProgressListResponse(tasks=tasks)
@router.post("/{measurement_id}/authorise", response_model=MeasurementResponse)
async def authorise_measurement(
measurement_id: int,
action: MeasurementAuthorisation,
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Let an out-of-tolerance value stand, on the capoturno's name.
Credentials go to this endpoint rather than to a separate check because the
approval has to end up attached to the measurement. A check whose answer is
thrown away is what this point had before: a modal that closed.
"""
result = await db.execute(
select(Measurement).where(Measurement.id == measurement_id)
)
measurement = result.scalar_one_or_none()
if measurement is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Measurement not found",
)
if measurement.pass_fail != "fail":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This measurement is within tolerance: there is nothing to authorise",
)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
)
measurement = await measurement_service.authorise_measurement(
db, measurement, supervisor,
)
return MeasurementResponse.model_validate(measurement)
@router.post("/batch", response_model=list[MeasurementResponse]) @router.post("/batch", response_model=list[MeasurementResponse])
async def create_measurement_batch( async def create_measurement_batch(
data: MeasurementBatchCreate, data: MeasurementBatchCreate,
@@ -74,6 +151,8 @@ async def create_measurement_batch(
lot_number=measurement_data.lot_number, lot_number=measurement_data.lot_number,
serial_number=measurement_data.serial_number, serial_number=measurement_data.serial_number,
input_method=measurement_data.input_method, input_method=measurement_data.input_method,
input_duration_ms=measurement_data.input_duration_ms,
production_run_id=measurement_data.production_run_id,
) )
measurements.append(measurement) measurements.append(measurement)
return [MeasurementResponse.model_validate(m) for m in measurements] return [MeasurementResponse.model_validate(m) for m in measurements]
@@ -84,24 +163,18 @@ async def create_measurement_batch(
) )
@router.get("/", response_model=MeasurementListResponse) def _build_measurement_filters(
async def get_measurements( recipe_id: int | None,
recipe_id: int | None = Query(None), version_id: int | None,
version_id: int | None = Query(None), subtask_id: int | None,
subtask_id: int | None = Query(None), measured_by: int | None,
measured_by: int | None = Query(None), lot_number: str | None,
lot_number: str | None = Query(None), serial_number: str | None,
serial_number: str | None = Query(None), date_from: datetime | None,
date_from: datetime | None = Query(None), date_to: datetime | None,
date_to: datetime | None = Query(None), pass_fail: str | None,
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), ) -> list:
page: int = Query(1, ge=1), """Build SQLAlchemy filter conditions shared by list and CSV export."""
per_page: int = Query(50, ge=1, le=500),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
):
"""Query measurements with filters and pagination."""
# Build filter conditions
filters = [] filters = []
if recipe_id is not None: if recipe_id is not None:
# Filter by recipe via subquery on RecipeVersion # Filter by recipe via subquery on RecipeVersion
@@ -125,6 +198,37 @@ async def get_measurements(
filters.append(Measurement.measured_at <= date_to) filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None: if pass_fail is not None:
filters.append(Measurement.pass_fail == pass_fail) filters.append(Measurement.pass_fail == pass_fail)
return filters
@router.get("/", response_model=MeasurementListResponse)
async def get_measurements(
recipe_id: int | None = Query(None),
version_id: int | None = Query(None),
subtask_id: int | None = Query(None),
measured_by: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
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(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""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.
"""
filters = _build_measurement_filters(
recipe_id, version_id, subtask_id, measured_by, lot_number,
serial_number, date_from, date_to, pass_fail,
)
# Build query # Build query
query = select(Measurement).where(and_(*filters) if filters else True) query = select(Measurement).where(and_(*filters) if filters else True)
@@ -189,29 +293,10 @@ async def export_measurements_csv(
decimal_setting = decimal_result.scalar_one_or_none() decimal_setting = decimal_result.scalar_one_or_none()
decimal_separator = decimal_setting.setting_value if decimal_setting else "." decimal_separator = decimal_setting.setting_value if decimal_setting else "."
# Build filter conditions (same as get_measurements) filters = _build_measurement_filters(
filters = [] recipe_id, version_id, subtask_id, measured_by, lot_number,
if recipe_id is not None: serial_number, date_from, date_to, pass_fail,
version_ids = select(RecipeVersion.id).where( )
RecipeVersion.recipe_id == recipe_id
)
filters.append(Measurement.version_id.in_(version_ids))
if version_id is not None:
filters.append(Measurement.version_id == version_id)
if subtask_id is not None:
filters.append(Measurement.subtask_id == subtask_id)
if measured_by is not None:
filters.append(Measurement.measured_by == measured_by)
if lot_number is not None:
filters.append(Measurement.lot_number == lot_number)
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
if date_from is not None:
filters.append(Measurement.measured_at >= date_from)
if date_to is not None:
filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None:
filters.append(Measurement.pass_fail == pass_fail)
# Query all matching measurements # Query all matching measurements
query = select(Measurement).where(and_(*filters) if filters else True) query = select(Measurement).where(and_(*filters) if filters else True)
+163
View File
@@ -0,0 +1,163 @@
"""Production runs router - open, read, and drive the life of a production.
Deliberately stateless: nothing lives in process memory. With one station app
installed per PC the database is the only shared place, and any station app must be
able to ask "what is going on here?" and get the same answer.
"""
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.middleware.api_key import get_current_user
from src.backend.database import get_db
from src.backend.models.api.production import (
CycleCompletePayload,
ProductionEventResponse,
ProductionRunCreate,
ProductionRunResponse,
ProductionRunWithEventsResponse,
RemeasurePayload,
SupervisorAction,
)
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.user import User
from src.backend.services import auth_service, production_service
router = APIRouter(prefix="/api/production-runs", tags=["production"])
_DERIVED = {
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
}
async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunResponse:
"""The stored run plus everything the client cannot work out on its own."""
return ProductionRunResponse(
**ProductionRunResponse.model_validate(run).model_dump(exclude=_DERIVED),
**production_service.describe(run),
measurement_task_ids=await production_service.measurement_task_ids(
db, run.version_id,
),
)
@router.post("", response_model=ProductionRunResponse, status_code=status.HTTP_201_CREATED)
async def open_production_run(
data: ProductionRunCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Start a production at a station."""
run = await production_service.open_run(db, data, user)
return await _as_response(db, run)
@router.get("/current", response_model=ProductionRunResponse | None)
async def get_current_production_run(
station_code: str = Query(..., min_length=1),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""The run open at this station, or null.
This is what every page asks on load instead of keeping the timer in memory.
"""
station = await production_service.get_station_by_code(db, station_code)
run = await production_service.get_open_run_for_station(db, station.id)
return await _as_response(db, run) if run is not None else None
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
async def get_production_run(
run_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""A run with its full trace - the history a production did not have before."""
run = await production_service.get_run(db, run_id)
events = await production_service.list_run_events(db, run_id)
return ProductionRunWithEventsResponse(
**(await _as_response(db, run)).model_dump(),
events=[ProductionEventResponse.model_validate(e) for e in events],
)
@router.post("/{run_id}/cycle", response_model=ProductionRunResponse)
async def complete_measurement_cycle(
run_id: int,
payload: CycleCompletePayload | None = None,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Record a finished measurement task; the last one restarts the interval."""
run = await production_service.get_run(db, run_id)
run = await production_service.complete_cycle(
db, run, user,
task_id=payload.task_id if payload else None,
note=payload.note if payload else None,
)
return await _as_response(db, run)
@router.post("/{run_id}/remeasure", response_model=ProductionRunResponse)
async def remeasure_in_cycle(
run_id: int,
payload: RemeasurePayload | None = None,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Turn the piece over and measure again, without closing the cycle."""
run = await production_service.get_run(db, run_id)
run = await production_service.remeasure(
db, run, user,
task_id=payload.task_id if payload else None,
note=payload.note if payload else None,
)
return await _as_response(db, run)
@router.post("/{run_id}/pause", response_model=ProductionRunResponse)
async def pause_production_run(
run_id: int,
action: SupervisorAction,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Fermo linea - requires a supervisor."""
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
)
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
@router.post("/{run_id}/resume", response_model=ProductionRunResponse)
async def resume_production_run(
run_id: int,
action: SupervisorAction,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Restart a stopped line - requires a supervisor."""
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
)
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
@router.post("/{run_id}/close", response_model=ProductionRunResponse)
async def close_production_run(
run_id: int,
action: SupervisorAction,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Fine produzione - requires a supervisor. Stops the timer for good."""
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
)
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
@@ -1,18 +1,20 @@
"""Recipe router - CRUD, versioning, barcode lookup.""" """Recipe router - CRUD, versioning, barcode lookup, AI parsing."""
from fastapi import APIRouter, Depends, Query import logging
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import get_current_user, require_maker, require_measurement_tec from src.backend.api.middleware.api_key import get_current_user, require_maker, require_measurement_tec
from models.user import User from src.backend.models.orm.user import User
from schemas.recipe import ( from src.backend.models.api.recipe import (
RecipeCreate, RecipeCreate,
RecipeListResponse, RecipeListResponse,
RecipeResponse, RecipeResponse,
RecipeUpdate, RecipeUpdate,
RecipeVersionResponse, RecipeVersionResponse,
) )
from services import recipe_service from src.backend.services import recipe_service
router = APIRouter(prefix="/api/recipes", tags=["recipes"]) router = APIRouter(prefix="/api/recipes", tags=["recipes"])
@@ -164,3 +166,31 @@ async def get_measurement_count(
""" """
count = await recipe_service.get_measurement_count(db, recipe_id, version_number) count = await recipe_service.get_measurement_count(db, recipe_id, version_number)
return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count} return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count}
@router.post("/{recipe_id}/parse-technical-sheet")
async def parse_technical_sheet(
recipe_id: int,
file: UploadFile = File(...),
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Parse a PDF technical sheet using AI and return suggested tasks."""
from src.backend.services import ai_service
if not file.content_type or "pdf" not in file.content_type:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Il file deve essere un PDF")
pdf_bytes = await file.read()
if len(pdf_bytes) > 20 * 1024 * 1024:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File troppo grande (max 20MB)")
try:
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logging.getLogger(__name__).error("AI parsing error: %s", e)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
return {"recipe_id": recipe_id, "suggested_tasks": tasks}
@@ -5,10 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import require_metrologist from src.backend.api.middleware.api_key import require_metrologist
from models.user import User from src.backend.models.orm.user import User
from services.report_service import generate_measurement_report, generate_spc_report from src.backend.services.report_service import generate_measurement_report, generate_spc_report
router = APIRouter(prefix="/api/reports", tags=["reports"]) router = APIRouter(prefix="/api/reports", tags=["reports"])
@@ -5,11 +5,11 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from src.backend.config import settings
from database import get_db from src.backend.database import get_db
from middleware.api_key import get_current_user, require_admin_user from src.backend.api.middleware.api_key import get_current_user, require_admin_user
from models.setting import SystemSetting from src.backend.models.orm.setting import SystemSetting
from models.user import User from src.backend.models.orm.user import User
router = APIRouter(prefix="/api/settings", tags=["settings"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -15,18 +15,19 @@ from pydantic import BaseModel
from sqlalchemy import inspect as sa_inspect, select from sqlalchemy import inspect as sa_inspect, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from src.backend.api.routers.users import VALID_ROLES
from database import Base, engine, async_session_factory from src.backend.config import settings
from models import ( from src.backend.database import Base, engine, async_session_factory
from src.backend.models.orm import (
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
) )
from models.station import Station, StationRecipeAssignment from src.backend.models.orm.station import Station, StationRecipeAssignment
from services.auth_service import hash_password from src.backend.services.auth_service import hash_password
from services.measurement_service import calculate_pass_fail from src.backend.services.measurement_service import calculate_pass_fail
router = APIRouter(prefix="/api/setup", tags=["setup"]) router = APIRouter(prefix="/api/setup", tags=["setup"])
_templates_dir = Path(__file__).resolve().parent.parent / "templates" _templates_dir = Path(__file__).resolve().parent.parent.parent / "templates"
templates = Jinja2Templates(directory=str(_templates_dir)) templates = Jinja2Templates(directory=str(_templates_dir))
@@ -215,7 +216,7 @@ async def _seed_default_station(session: AsyncSession, admin_user: User) -> None
async def setup_page(request: Request): async def setup_page(request: Request):
"""Serve the setup page HTML.""" """Serve the setup page HTML."""
_check_setup_enabled() _check_setup_enabled()
return templates.TemplateResponse("setup/setup.html", {"request": request}) return templates.TemplateResponse(request, "setup/setup.html")
@router.get("/status") @router.get("/status")
@@ -544,7 +545,6 @@ async def manage_user(body: ManageUserBody):
"""Create or update a user. user_id=null creates new, user_id=int updates.""" """Create or update a user. user_id=null creates new, user_id=int updates."""
_check_password(body.password) _check_password(body.password)
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist"}
invalid = set(body.roles) - VALID_ROLES invalid = set(body.roles) - VALID_ROLES
if invalid: if invalid:
raise HTTPException( raise HTTPException(
@@ -2,31 +2,54 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import get_current_user, require_admin_user from src.backend.api.middleware.api_key import get_current_user, require_admin_user
from models.user import User from src.backend.models.orm.user import User
from schemas.station import ( from src.backend.models.api.station import (
StationCreate, StationCreate,
StationUpdate, StationUpdate,
StationResponse, StationResponse,
StationRecipeAssignmentCreate, StationRecipeAssignmentCreate,
StationRecipeAssignmentResponse, StationRecipeAssignmentResponse,
StationResetResponse,
StationWithRecipesResponse,
RecipeSummary, RecipeSummary,
) )
from services import station_service from src.backend.services import station_service
router = APIRouter(prefix="/api/stations", tags=["stations"]) router = APIRouter(prefix="/api/stations", tags=["stations"])
@router.get("", response_model=list[StationResponse]) @router.get("", response_model=list[StationWithRecipesResponse])
async def list_stations( async def list_stations(
active_only: bool = False, active_only: bool = False,
admin: User = Depends(require_admin_user), admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""List all stations (admin only).""" """List all stations with the recipes assigned to each (admin only).
The admin station list shows which products a station handles, so the
assignments travel with the station instead of needing a call per row.
Station.assignments is selectin-loaded, so this costs no extra query.
Only active recipes are listed, matching what an operator actually sees
at that station via /by-code/{code}/recipes.
"""
stations = await station_service.list_stations(db, active_only=active_only) stations = await station_service.list_stations(db, active_only=active_only)
return [StationResponse.model_validate(s) for s in stations] return [
StationWithRecipesResponse(
**StationResponse.model_validate(s).model_dump(),
recipes=sorted(
(
RecipeSummary.model_validate(a.recipe)
for a in s.assignments
if a.recipe is not None and a.recipe.active
),
key=lambda r: r.code,
),
)
for s in stations
]
@router.post("", response_model=StationResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=StationResponse, status_code=status.HTTP_201_CREATED)
@@ -128,6 +151,22 @@ async def assign_recipe_to_station(
return StationRecipeAssignmentResponse.model_validate(assignment) return StationRecipeAssignmentResponse.model_validate(assignment)
@router.delete("/{station_id}/recipes", response_model=StationResetResponse)
async def reset_station_recipes(
station_id: int,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Clear every recipe assignment of a station (admin only).
Backs the per-row reset in the admin list: the station is left with no
recipes and can be reassigned from scratch, without deleting the station
itself. Idempotent - resetting an already empty station reports removed=0.
"""
removed = await station_service.unassign_all_recipes(db, station_id)
return StationResetResponse(station_id=station_id, removed=removed)
@router.delete( @router.delete(
"/{station_id}/recipes/{recipe_id}", "/{station_id}/recipes/{recipe_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
@@ -5,19 +5,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, select from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import require_metrologist from src.backend.api.middleware.api_key import require_metrologist
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from models.recipe import RecipeVersion from src.backend.models.orm.recipe import RecipeVersion
from models.task import RecipeSubtask, RecipeTask from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from models.user import User from src.backend.models.orm.user import User
from schemas.statistics import ( from src.backend.models.api.statistics import (
CapabilityData, CapabilityData,
ControlChartData, ControlChartData,
HistogramData, HistogramData,
SummaryData, SummaryData,
) )
from services.spc_service import ( from src.backend.services.spc_service import (
compute_capability, compute_capability,
compute_control_chart, compute_control_chart,
compute_histogram, compute_histogram,
@@ -4,12 +4,12 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from database import get_db from src.backend.database import get_db
from middleware.api_key import require_maker, get_current_user from src.backend.api.middleware.api_key import require_maker, get_current_user
from models.recipe import Recipe, RecipeVersion from src.backend.models.orm.recipe import Recipe, RecipeVersion
from models.task import RecipeSubtask, RecipeTask from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from models.user import User from src.backend.models.orm.user import User
from schemas.task import ( from src.backend.models.api.task import (
SubtaskCreate, SubtaskCreate,
SubtaskResponse, SubtaskResponse,
SubtaskUpdate, SubtaskUpdate,
@@ -18,7 +18,7 @@ from schemas.task import (
TaskResponse, TaskResponse,
TaskUpdate, TaskUpdate,
) )
from services import recipe_service from src.backend.services import recipe_service
router = APIRouter(tags=["tasks"]) router = APIRouter(tags=["tasks"])
@@ -159,7 +159,7 @@ async def create_task(
if has_measurements: if has_measurements:
# Copy-on-write: create new version preserving measurement data # Copy-on-write: create new version preserving measurement data
from schemas.recipe import RecipeUpdate from src.backend.models.api.recipe import RecipeUpdate
new_version = await recipe_service.create_new_version( new_version = await recipe_service.create_new_version(
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
) )
@@ -172,12 +172,14 @@ async def create_task(
new_task = RecipeTask( new_task = RecipeTask(
version_id=new_version.id, version_id=new_version.id,
order_index=max_order + 1, order_index=max_order + 1,
task_type=data.task_type,
title=data.title, title=data.title,
directive=data.directive, directive=data.directive,
description=data.description, description=data.description,
file_path=data.file_path, file_path=data.file_path,
file_type=data.file_type, file_type=data.file_type,
annotations_json=data.annotations_json, annotations_json=data.annotations_json,
vision_json=data.vision_json,
) )
db.add(new_task) db.add(new_task)
await db.flush() await db.flush()
@@ -196,6 +198,7 @@ async def create_task(
ltl=sub_data.ltl, ltl=sub_data.ltl,
unit=sub_data.unit, unit=sub_data.unit,
image_path=sub_data.image_path, image_path=sub_data.image_path,
vision_output=sub_data.vision_output,
) )
db.add(sub) db.add(sub)
@@ -223,7 +226,10 @@ async def reorder_tasks(
result = await db.execute( result = await db.execute(
select(RecipeTask) select(RecipeTask)
.where(RecipeTask.id.in_(data.task_ids)) .where(RecipeTask.id.in_(data.task_ids))
.options(selectinload(RecipeTask.subtasks)) .options(
selectinload(RecipeTask.subtasks),
selectinload(RecipeTask.version),
)
) )
tasks_map = {t.id: t for t in result.scalars().all()} tasks_map = {t.id: t for t in result.scalars().all()}
@@ -336,6 +342,7 @@ async def create_subtask(
ltl=data.ltl, ltl=data.ltl,
unit=data.unit, unit=data.unit,
image_path=data.image_path, image_path=data.image_path,
vision_output=data.vision_output,
) )
db.add(subtask) db.add(subtask)
await db.flush() await db.flush()
@@ -3,14 +3,18 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import require_admin_user from src.backend.api.middleware.api_key import require_admin_user
from models.user import User from src.backend.models.orm.user import User
from schemas.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
from services.auth_service import create_user, hash_password, regenerate_api_key from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key
router = APIRouter(prefix="/api/users", tags=["users"]) router = APIRouter(prefix="/api/users", tags=["users"])
# Combinable user roles. Supervisor (capoturno) authorizes out-of-tolerance
# overrides during measurement without needing full admin privileges.
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist", "Supervisor"}
@router.get("", response_model=list[UserResponse]) @router.get("", response_model=list[UserResponse])
async def list_users( async def list_users(
@@ -40,12 +44,11 @@ async def create_new_user(
detail=f"Username '{data.username}' already exists", detail=f"Username '{data.username}' already exists",
) )
# Validate roles # Validate roles
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
for role in data.roles: for role in data.roles:
if role not in valid_roles: if role not in VALID_ROLES:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}", detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
) )
user = await create_user( user = await create_user(
db, db,
@@ -77,12 +80,11 @@ async def update_user(
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
# Validate roles if provided # Validate roles if provided
if "roles" in update_data: if "roles" in update_data:
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
for role in update_data["roles"]: for role in update_data["roles"]:
if role not in valid_roles: if role not in VALID_ROLES:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}", detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
) )
for field, value in update_data.items(): for field, value in update_data.items():
setattr(user, field, value) setattr(user, field, value)
+133
View File
@@ -0,0 +1,133 @@
"""The client asks for a measurement and gets an outcome.
It does not know, and must not know, whether the server or the station computed
the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes.
"""
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, validate_file_size
from src.backend.config import settings
from src.backend.database import get_db
from src.backend.models.api.vision import VisionExecuteResponse, VisionMeasurementResult
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import vision_service
router = APIRouter(prefix="/api/vision", tags=["vision"])
# A vision graph measures pixels, not a PDF: stricter than files.py's
# ALLOWED_TYPES, which also accepts documents for technical drawings.
_IMAGE_EXTENSIONS = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
async def _task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
"""Shared by the endpoints of this router."""
task = (await db.execute(
select(RecipeTask).where(RecipeTask.id == task_id)
)).scalar_one_or_none()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
return task
async def _read_image(image: UploadFile) -> bytes:
"""Type checked from the header before reading, size checked once the body
is in hand - the same two-step convention `files.py::upload_file` already
uses, reused rather than reinvented here.
"""
if image.content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {image.content_type} not allowed. Must be an image.",
)
content = await image.read()
if not validate_file_size(len(content)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"File size {len(content)} bytes exceeds maximum "
f"{settings.max_upload_size_mb}MB"
),
)
return content
@router.post("/execute", response_model=VisionExecuteResponse)
async def execute(
task_id: int = Form(...),
image: UploadFile = File(...),
lot_number: str | None = Form(None),
serial_number: str | None = Form(None),
production_run_id: int | None = Form(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> VisionExecuteResponse:
task = await _task_or_404(db, task_id)
result, saved = await vision_service.execute_task(
db,
task,
await _read_image(image),
current_user.id,
version_id=task.version_id,
lot_number=lot_number,
serial_number=serial_number,
production_run_id=production_run_id,
)
await db.commit()
return VisionExecuteResponse(
vision_result_id=result.id,
engine_version=result.engine_version,
measurements=[
VisionMeasurementResult(
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
)
for m in saved
],
)
# Composing a recipe and keeping a reference image are Maker operations, not
# measuring: both endpoints below gate on `require_maker`, not the
# `require_measurement_tec` that `execute` above uses.
@router.post("/reference-images")
async def add_reference_image(
task_id: int = Form(...),
image: UploadFile = File(...),
note: str | None = Form(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
task = await _task_or_404(db, task_id)
content = await _read_image(image)
reference = await vision_service.save_reference_image(
db, task.id, content, note=note,
extension=_IMAGE_EXTENSIONS[image.content_type],
)
await db.commit()
return {"id": reference.id, "path": reference.path, "note": reference.note}
@router.post("/preview")
async def preview(
task_id: int = Form(...),
image: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
task = await _task_or_404(db, task_id)
return await vision_service.preview(db, task, await _read_image(image))
+23 -5
View File
@@ -23,9 +23,9 @@ class Settings(BaseSettings):
upload_dir: str = "uploads" upload_dir: str = "uploads"
max_upload_size_mb: int = 50 max_upload_size_mb: int = 50
# Rate Limiting (requests per minute) # Rate Limiting (requests per minute, per real client IP)
rate_limit_login: int = 5 rate_limit_login: int = 5
rate_limit_general: int = 100 rate_limit_general: int = 300
# SSL (Production) # SSL (Production)
ssl_certfile: str | None = None ssl_certfile: str | None = None
@@ -34,6 +34,13 @@ class Settings(BaseSettings):
# Setup page (empty = disabled) # Setup page (empty = disabled)
setup_password: str | None = None setup_password: str | None = None
# AI / OpenRouter (for technical sheet parsing)
openrouter_api_key: str | None = None
openrouter_model: str = "anthropic/claude-sonnet-4"
# Vision worker (internal network only, reachable as `vision` in Compose)
vision_worker_url: str = "http://vision:8100"
@property @property
def database_url(self) -> str: def database_url(self) -> str:
"""Async MySQL connection string.""" """Async MySQL connection string."""
@@ -49,10 +56,21 @@ class Settings(BaseSettings):
@property @property
def upload_path(self) -> Path: def upload_path(self) -> Path:
"""Absolute path to upload directory.""" """Absolute path to upload directory.
return Path(__file__).parent / self.upload_dir
model_config = {"env_file": "../.env", "env_file_encoding": "utf-8", "extra": "ignore"} After the V2.0.0 restructure, uploads live at the project root
(mounted as a Docker volume), not inside the backend tree.
"""
# Path(__file__) = src/backend/config.py → parents[2] = project root
return Path(__file__).resolve().parents[2] / self.upload_dir
# 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() settings = Settings()
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import (
) )
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from config import settings from src.backend.config import settings
# Create async engine # Create async engine
engine = create_async_engine( engine = create_async_engine(
+20 -16
View File
@@ -5,22 +5,24 @@ from collections.abc import AsyncGenerator
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import settings from src.backend.config import settings
from database import init_db from src.backend.database import init_db
from middleware.logging import AccessLogMiddleware from src.backend.api.middleware.logging import AccessLogMiddleware
from middleware.rate_limit import RateLimitMiddleware from src.backend.api.middleware.rate_limit import RateLimitMiddleware
from middleware.security_headers import SecurityHeadersMiddleware from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
from routers.auth import router as auth_router from src.backend.api.routers.auth import router as auth_router
from routers.users import router as users_router from src.backend.api.routers.users import router as users_router
from routers.recipes import router as recipes_router from src.backend.api.routers.recipes import router as recipes_router
from routers.tasks import router as tasks_router from src.backend.api.routers.tasks import router as tasks_router
from routers.measurements import router as measurements_router from src.backend.api.routers.measurements import router as measurements_router
from routers.files import router as files_router from src.backend.api.routers.files import router as files_router
from routers.settings import router as settings_router from src.backend.api.routers.settings import router as settings_router
from routers.reports import router as reports_router from src.backend.api.routers.reports import router as reports_router
from routers.statistics import router as statistics_router from src.backend.api.routers.statistics import router as statistics_router
from routers.setup import router as setup_router from src.backend.api.routers.setup import router as setup_router
from routers.stations import router as stations_router from src.backend.api.routers.stations import router as stations_router
from src.backend.api.routers.production import router as production_router
from src.backend.api.routers.vision import router as vision_router
@asynccontextmanager @asynccontextmanager
@@ -73,6 +75,8 @@ app.include_router(statistics_router)
app.include_router(reports_router) app.include_router(reports_router)
app.include_router(setup_router) app.include_router(setup_router)
app.include_router(stations_router) app.include_router(stations_router)
app.include_router(production_router)
app.include_router(vision_router)
@app.get("/api/health") @app.get("/api/health")
@@ -17,20 +17,27 @@ if config.config_file_name is not None:
import sys import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # Add the project root (4 levels up from this file) to sys.path so that
from config import settings # `src.backend.*` imports resolve when alembic is invoked from anywhere.
from database import Base _PROJECT_ROOT = Path(__file__).resolve().parents[3]
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
from src.backend.config import settings
from src.backend.database import Base
# Override alembic.ini URL with .env settings (keep in sync) # Override alembic.ini URL with .env settings (keep in sync)
config.set_main_option("sqlalchemy.url", settings.database_url) config.set_main_option("sqlalchemy.url", settings.database_url)
# Import all models so they register with Base.metadata # Import all models so they register with Base.metadata
from models.user import User # noqa: F401 from src.backend.models.orm.user import User # noqa: F401
from models.recipe import Recipe, RecipeVersion # noqa: F401 from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: F401
from models.task import RecipeTask, RecipeSubtask # noqa: F401 from src.backend.models.orm.task import RecipeTask, RecipeSubtask # noqa: F401
from models.measurement import Measurement # noqa: F401 from src.backend.models.orm.measurement import Measurement # noqa: F401
from models.access_log import AccessLog # noqa: F401 from src.backend.models.orm.access_log import AccessLog # noqa: F401
from models.setting import SystemSetting, RecipeVersionAudit # noqa: F401 from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401
from src.backend.models.orm.station import Station, StationRecipeAssignment # noqa: F401
from src.backend.models.orm.production import ProductionRun, ProductionEvent # noqa: F401
target_metadata = Base.metadata target_metadata = Base.metadata
@@ -0,0 +1,24 @@
"""add measurement_interval_minutes to recipes
Revision ID: 003_measurement_interval
Revises: 002_add_stations
Create Date: 2026-05-23
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '003_measurement_interval'
down_revision: Union[str, None] = '002_add_stations'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('recipes', sa.Column('measurement_interval_minutes', sa.SmallInteger(), nullable=True))
def downgrade() -> None:
op.drop_column('recipes', 'measurement_interval_minutes')
@@ -0,0 +1,27 @@
"""add input_duration_ms to measurements
Revision ID: 004_input_duration
Revises: 003_measurement_interval
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '004_input_duration'
down_revision: Union[str, None] = '003_measurement_interval'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'measurements',
sa.Column('input_duration_ms', sa.Integer, nullable=True),
)
def downgrade() -> None:
op.drop_column('measurements', 'input_duration_ms')
@@ -0,0 +1,100 @@
"""add production_runs and production_events
Gives a production a life of its own: before this its state lived in the Alpine
component of task_execute.html, so changing task - a full page load - lost the timer,
the cycle count and the "production started" flag.
Revision ID: 005_production_runs
Revises: 004_input_duration
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '005_production_runs'
down_revision: Union[str, None] = '004_input_duration'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'production_runs',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('station_id', sa.Integer, sa.ForeignKey('stations.id'), nullable=False),
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False),
sa.Column('version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'), nullable=False),
sa.Column('operator_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
sa.Column('lot_number', sa.String(100), nullable=True),
sa.Column('serial_number', sa.String(100), nullable=True),
sa.Column(
'status',
sa.Enum('running', 'paused', 'closed', name='production_run_status_enum'),
nullable=False,
server_default='running',
),
sa.Column('measurement_interval_minutes', sa.SmallInteger, nullable=True),
sa.Column('next_measurement_at', sa.DateTime, nullable=True),
sa.Column('cycle_count', sa.Integer, nullable=False, server_default='0'),
sa.Column('started_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('paused_at', sa.DateTime, nullable=True),
sa.Column('closed_at', sa.DateTime, nullable=True),
sa.Column('closed_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
# Mirrors station_id while open, NULL once closed. The unique constraint makes
# "one open run per station" a database guarantee rather than a race; repeated
# NULLs do not collide, so closed runs are free to pile up.
sa.Column('active_station_id', sa.Integer, nullable=True),
# Declared inline rather than added afterwards: adding a constraint is an ALTER,
# which SQLite cannot do, and the test databases are SQLite.
sa.UniqueConstraint('active_station_id', name='uq_production_runs_active_station'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_index('ix_production_runs_station_id', 'production_runs', ['station_id'])
op.create_index('ix_production_runs_recipe_id', 'production_runs', ['recipe_id'])
op.create_index('ix_production_runs_version_id', 'production_runs', ['version_id'])
op.create_index('ix_production_runs_operator_id', 'production_runs', ['operator_id'])
op.create_index('ix_production_runs_lot_number', 'production_runs', ['lot_number'])
op.create_index('ix_production_runs_serial_number', 'production_runs', ['serial_number'])
op.create_index('ix_production_runs_status', 'production_runs', ['status'])
op.create_index(
'ix_production_runs_next_measurement_at', 'production_runs', ['next_measurement_at'],
)
op.create_index(
'ix_production_runs_station_status', 'production_runs', ['station_id', 'status'],
)
op.create_table(
'production_events',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column(
'run_id', sa.Integer,
sa.ForeignKey('production_runs.id', ondelete='CASCADE'), nullable=False,
),
sa.Column(
'event_type',
sa.Enum(
'start', 'cycle_completed', 'line_stop', 'resume', 'close',
name='production_event_type_enum',
),
nullable=False,
),
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
sa.Column('supervisor_id', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
sa.Column('note', sa.Text, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_index('ix_production_events_run_id', 'production_events', ['run_id'])
op.create_index('ix_production_events_event_type', 'production_events', ['event_type'])
op.create_index('ix_production_events_user_id', 'production_events', ['user_id'])
op.create_index('ix_production_events_created_at', 'production_events', ['created_at'])
def downgrade() -> None:
op.drop_table('production_events')
op.drop_table('production_runs')
@@ -0,0 +1,63 @@
"""link measurements to their production run
Fine produzione has to hand the measurements of the whole production to the
statistics file. Without this column "the measurements of this production" is not a
query: measurements only knew their recipe version, lot and serial, none of which
delimits one run from the next on the same recipe and lot.
Nullable on purpose: measurements taken before this - and any taken outside a
production - simply have no run.
Revision ID: 006_measurement_run
Revises: 005_production_runs
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '006_measurement_run'
down_revision: Union[str, None] = '005_production_runs'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Batch mode, because the column carries a foreign key: adding a constraint is
# an ALTER that SQLite cannot do, and alembic raises rather than silently
# dropping it. On MySQL this is a plain ALTER; on SQLite it rebuilds the table.
# Keeping the key is worth the ceremony - ON DELETE SET NULL means a measurement,
# the record that matters in an audit, survives its run being deleted.
with op.batch_alter_table('measurements') as batch_op:
batch_op.add_column(
sa.Column(
'production_run_id', sa.Integer,
sa.ForeignKey(
'production_runs.id',
ondelete='SET NULL',
# Named because batch mode requires it, and because an anonymous
# constraint cannot be referred to later.
name='fk_measurements_production_run',
),
nullable=True,
),
)
op.create_index(
'ix_measurements_production_run_id', 'measurements', ['production_run_id'],
)
# Where the statistics file for a closed run was written. No constraint, so a
# plain ALTER is enough.
op.add_column(
'production_runs',
sa.Column('statistics_path', sa.String(500), nullable=True),
)
def downgrade() -> None:
op.drop_column('production_runs', 'statistics_path')
op.drop_index('ix_measurements_production_run_id', table_name='measurements')
with op.batch_alter_table('measurements') as batch_op:
batch_op.drop_column('production_run_id')
@@ -0,0 +1,64 @@
"""add an explicit type to recipe tasks
Until now the kind of a task was deduced: quotes present meant a measurement,
otherwise a note. A measurement task whose quotes had not been entered yet was
therefore treated as a note - the system behaved differently depending on how
complete the recipe happened to be.
Existing rows are classified by the rule that was in force, so nothing changes
behaviour on upgrade: tasks with quotes become 'measure'. Tasks with no quotes but
a drawing attached become 'drawing' rather than 'note' - that is what they already
displayed as, and calling them notes would be the one place this migration did
change behaviour.
Revision ID: 007_task_type
Revises: 006_measurement_run
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '007_task_type'
down_revision: Union[str, None] = '006_measurement_run'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TASK_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
def upgrade() -> None:
op.add_column(
'recipe_tasks',
sa.Column(
'task_type',
sa.Enum(*TASK_TYPES, name='task_type_enum'),
nullable=False,
server_default='note',
),
)
op.create_index('ix_recipe_tasks_task_type', 'recipe_tasks', ['task_type'])
# Backfill by the rule the frontend used, so no recipe changes behaviour.
op.execute(
"""
UPDATE recipe_tasks
SET task_type = 'measure'
WHERE id IN (SELECT DISTINCT task_id FROM recipe_subtasks)
"""
)
op.execute(
"""
UPDATE recipe_tasks
SET task_type = 'drawing'
WHERE task_type = 'note'
AND file_path IS NOT NULL
"""
)
def downgrade() -> None:
op.drop_index('ix_recipe_tasks_task_type', table_name='recipe_tasks')
op.drop_column('recipe_tasks', 'task_type')
@@ -0,0 +1,74 @@
"""widen the production event trace for the measurement loop
The loop needs to distinguish two things the trace could not say before: a
measurement task finished while the cycle is still running (task_measured), and the
piece turned over and measured again without closing the cycle (remeasure). Only
the last measurement task of a recipe still produces cycle_completed, because only
that one restarts the interval.
Both values are added in one go. Widening a MySQL enum rewrites the table, so the
values that are already planned go in now rather than one migration at a time -
the same reasoning as the task types in 007.
The events that happen inside a cycle also need to say which task they are about,
so the column comes with them: a task_measured that does not name its task records
nothing usable.
Revision ID: 008_loop_events
Revises: 007_task_type
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '008_loop_events'
down_revision: Union[str, None] = '007_task_type'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_TYPES = ('start', 'cycle_completed', 'line_stop', 'resume', 'close')
NEW_TYPES = (
'start', 'cycle_completed', 'task_measured', 'remeasure',
'line_stop', 'resume', 'close',
)
def _alter(values: Sequence[str]) -> None:
"""Restate the column with a new set of allowed values.
On MySQL this is the ALTER that rewrites the enum. On SQLite, where an enum is
stored as a plain VARCHAR, batch mode rebuilds the table instead of emitting an
ALTER the engine does not support - the tests run there.
"""
with op.batch_alter_table('production_events') as batch:
batch.alter_column(
'event_type',
existing_type=sa.Enum(*OLD_TYPES, name='production_event_type_enum'),
type_=sa.Enum(*values, name='production_event_type_enum'),
existing_nullable=False,
)
def upgrade() -> None:
_alter(NEW_TYPES)
with op.batch_alter_table('production_events') as batch:
batch.add_column(sa.Column('task_id', sa.Integer(), nullable=True))
batch.create_foreign_key(
'fk_production_events_task_id', 'recipe_tasks', ['task_id'], ['id'],
)
def downgrade() -> None:
with op.batch_alter_table('production_events') as batch:
batch.drop_constraint('fk_production_events_task_id', type_='foreignkey')
batch.drop_column('task_id')
# The new kinds of event have no equivalent in the old vocabulary, and a row
# left with a value the column no longer accepts would fail the rebuild.
op.execute(
"DELETE FROM production_events "
"WHERE event_type IN ('task_measured', 'remeasure')"
)
_alter(OLD_TYPES)
@@ -0,0 +1,57 @@
"""traceability and manual input become rules of the recipe
Three settings that were nowhere: whether the lot and the serial are compulsory,
and whether a value may be typed instead of read from the caliper. Until now lot
and serial were optional everywhere and the keypad was always available, so a
value that happens to be in tolerance could simply be entered by hand.
Backfill: the two traceability flags start false, which is exactly today's
behaviour. Manual input starts *true* on the recipes that already exist - the
column default is false, so recipes written from now on are caliper-only, but
flipping the ones already in use would stop a running line at the next
measurement. Turning them off is a decision for whoever owns the recipe, taken in
the editor, not a side effect of an upgrade.
Revision ID: 009_recipe_rules
Revises: 008_loop_events
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '009_recipe_rules'
down_revision: Union[str, None] = '008_loop_events'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'recipes',
sa.Column(
'requires_lot', sa.Boolean(), nullable=False, server_default='0',
),
)
op.add_column(
'recipes',
sa.Column(
'requires_serial', sa.Boolean(), nullable=False, server_default='0',
),
)
op.add_column(
'recipes',
sa.Column(
'allow_manual_input', sa.Boolean(), nullable=False, server_default='0',
),
)
# Recipes already in production keep the behaviour they were written under.
op.execute("UPDATE recipes SET allow_manual_input = 1")
def downgrade() -> None:
op.drop_column('recipes', 'allow_manual_input')
op.drop_column('recipes', 'requires_serial')
op.drop_column('recipes', 'requires_lot')
@@ -0,0 +1,49 @@
"""record who authorised an out-of-tolerance measurement
The supervisor gate existed on screen and nowhere else: the modal opened, the
credentials were checked, the modal closed, and nothing was written down. Nothing
depended on the answer either, so a value outside tolerance could be left behind
by dismissing the modal.
These two columns are what the rule now stands on. A failed measurement with no
supervisor on it is a measurement waiting for one, and while one is waiting the
operator cannot move to the next quote.
Existing rows are left null. Backfilling an authorisation that never happened
would be inventing an audit record; the fails already in the database belong to
production runs that are over, and nothing is waiting on them.
Revision ID: 010_meas_authorisation
Revises: 009_recipe_rules
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '010_meas_authorisation'
down_revision: Union[str, None] = '009_recipe_rules'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.add_column(sa.Column('supervisor_id', sa.Integer(), nullable=True))
batch.add_column(sa.Column('authorised_at', sa.DateTime(), nullable=True))
batch.create_foreign_key(
'fk_measurements_supervisor_id', 'users', ['supervisor_id'], ['id'],
)
batch.create_index(
'ix_measurements_supervisor_id', ['supervisor_id'],
)
def downgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.drop_index('ix_measurements_supervisor_id')
batch.drop_constraint('fk_measurements_supervisor_id', type_='foreignkey')
batch.drop_column('authorised_at')
batch.drop_column('supervisor_id')
@@ -0,0 +1,55 @@
"""the vision graph lives on the task, and the quote names its output
`xf_compare` was a typo for `dxf_compare`. No row uses either value yet, so
renaming costs one statement here; from the first saved task onwards it would
be a data migration and the typo would already be in the public API.
Revision ID: 011_vision_graph
Revises: 010_meas_authorisation
Create Date: 2026-08-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '011_vision_graph'
down_revision: Union[str, None] = '010_meas_authorisation'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
NEW_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
def upgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('vision_json', sa.JSON(), nullable=True))
batch.alter_column(
'task_type',
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
with op.batch_alter_table('recipe_subtasks') as batch:
batch.add_column(
sa.Column('vision_output', sa.String(length=120), nullable=True)
)
def downgrade() -> None:
with op.batch_alter_table('recipe_subtasks') as batch:
batch.drop_column('vision_output')
with op.batch_alter_table('recipe_tasks') as batch:
batch.alter_column(
'task_type',
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
batch.drop_column('vision_json')
@@ -0,0 +1,79 @@
"""one row per vision execution, and camera as an input method
The vision surroundings do not go on `measurements`: statistics and the export
read that table on every pass. And it would be wrong modelling anyway - one
acquisition produces N quotes, and image, overlay, device and engine version are
the same for all of them.
Revision ID: 012_vision_results
Revises: 011_vision_graph
Create Date: 2026-08-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '012_vision_results'
down_revision: Union[str, None] = '011_vision_graph'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_METHODS = ('usb_caliper', 'manual')
NEW_METHODS = ('usb_caliper', 'manual', 'camera')
def upgrade() -> None:
op.create_table(
'vision_results',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('task_id', sa.Integer(),
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
sa.Column('image_path', sa.String(length=500), nullable=True),
sa.Column('overlay_path', sa.String(length=500), nullable=True),
sa.Column('engine_version', sa.String(length=64), nullable=False),
sa.Column('executed_on',
sa.Enum('server', 'station', name='vision_executed_on_enum'),
nullable=False),
sa.Column('station_id', sa.Integer(),
sa.ForeignKey('stations.id'), nullable=True),
sa.Column('device_code', sa.String(length=100), nullable=True),
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
sa.Column('graph_snapshot', sa.JSON(), nullable=False),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('executed_at', sa.DateTime(), nullable=False,
server_default=sa.func.now()),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
with op.batch_alter_table('measurements') as batch:
batch.alter_column(
'input_method',
existing_type=sa.Enum(*OLD_METHODS, name='input_method_enum'),
type_=sa.Enum(*NEW_METHODS, name='input_method_enum'),
existing_nullable=False,
)
batch.add_column(
sa.Column('vision_result_id', sa.Integer(), nullable=True)
)
batch.create_foreign_key(
'fk_measurements_vision_result',
'vision_results', ['vision_result_id'], ['id'],
)
def downgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.drop_constraint('fk_measurements_vision_result',
type_='foreignkey')
batch.drop_column('vision_result_id')
batch.alter_column(
'input_method',
existing_type=sa.Enum(*NEW_METHODS, name='input_method_enum'),
type_=sa.Enum(*OLD_METHODS, name='input_method_enum'),
existing_nullable=False,
)
op.drop_table('vision_results')
@@ -0,0 +1,49 @@
"""reference images, with their provenance
A reference image without provenance is a trap: the graph is re-run months later,
different numbers come out, and there is no way to tell an updated engine from a
moved lens.
`expected_json` holds what the graph is supposed to produce on this image. It is
what makes a regression run possible after a VisionSuite upgrade - the safety net
that makes bumping the submodule sustainable with an engine on two hosts.
Revision ID: 013_reference_images
Revises: 012_vision_results
Create Date: 2026-08-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '013_reference_images'
down_revision: Union[str, None] = '012_vision_results'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'vision_reference_images',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('task_id', sa.Integer(),
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
sa.Column('path', sa.String(length=500), nullable=False),
sa.Column('station_id', sa.Integer(),
sa.ForeignKey('stations.id'), nullable=True),
sa.Column('device_code', sa.String(length=100), nullable=True),
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
sa.Column('engine_version', sa.String(length=64), nullable=True),
sa.Column('note', sa.String(length=500), nullable=True),
sa.Column('expected_json', sa.JSON(), nullable=True),
sa.Column('acquired_at', sa.DateTime(), nullable=False,
server_default=sa.func.now()),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
def downgrade() -> None:
op.drop_table('vision_reference_images')
@@ -1,19 +1,19 @@
"""Pydantic schemas for TieMeasureFlow API.""" """Pydantic schemas for TieMeasureFlow API."""
from schemas.measurement import ( from src.backend.models.api.measurement import (
MeasurementBatchCreate, MeasurementBatchCreate,
MeasurementCreate, MeasurementCreate,
MeasurementListResponse, MeasurementListResponse,
MeasurementQuery, MeasurementQuery,
MeasurementResponse, MeasurementResponse,
) )
from schemas.recipe import ( from src.backend.models.api.recipe import (
RecipeCreate, RecipeCreate,
RecipeListResponse, RecipeListResponse,
RecipeResponse, RecipeResponse,
RecipeUpdate, RecipeUpdate,
RecipeVersionResponse, RecipeVersionResponse,
) )
from schemas.statistics import ( from src.backend.models.api.statistics import (
AlertData, AlertData,
CapabilityData, CapabilityData,
ControlChartData, ControlChartData,
@@ -23,7 +23,7 @@ from schemas.statistics import (
SummaryData, SummaryData,
TrendData, TrendData,
) )
from schemas.task import ( from src.backend.models.api.task import (
SubtaskCreate, SubtaskCreate,
SubtaskResponse, SubtaskResponse,
SubtaskUpdate, SubtaskUpdate,
@@ -32,7 +32,7 @@ from schemas.task import (
TaskResponse, TaskResponse,
TaskUpdate, TaskUpdate,
) )
from schemas.user import ( from src.backend.models.api.user import (
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
UserCreate, UserCreate,
@@ -12,7 +12,15 @@ class MeasurementCreate(BaseModel):
value: float value: float
lot_number: Optional[str] = Field(None, max_length=100) lot_number: Optional[str] = Field(None, max_length=100)
serial_number: Optional[str] = Field(None, max_length=100) serial_number: Optional[str] = Field(None, max_length=100)
# "camera" is deliberately absent from this pattern, even though the ORM
# enum and the database allow it. Do not add it here: a camera
# measurement must be produced by vision_service.execute_task, never by a
# client posting JSON straight to this endpoint - there is no image, no
# graph and no engine_version behind a value that arrived this way.
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$") input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
input_duration_ms: Optional[int] = Field(None, ge=0)
# The production this belongs to, when one is open at the station.
production_run_id: Optional[int] = Field(None, gt=0)
class MeasurementBatchCreate(BaseModel): class MeasurementBatchCreate(BaseModel):
@@ -34,6 +42,12 @@ class MeasurementResponse(BaseModel):
lot_number: Optional[str] = None lot_number: Optional[str] = None
serial_number: Optional[str] = None serial_number: Optional[str] = None
input_method: str input_method: str
input_duration_ms: Optional[int] = None
production_run_id: Optional[int] = None
# Who let this value stand, when it was out of tolerance. Null on a value in
# tolerance - and on one that is not and is still waiting for an answer.
supervisor_id: Optional[int] = None
authorised_at: Optional[datetime] = None
measured_at: datetime measured_at: datetime
synced_to_csv: bool synced_to_csv: bool
@@ -47,6 +61,29 @@ class MeasurementListResponse(BaseModel):
pages: int pages: int
class MeasurementAuthorisation(BaseModel):
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
supervisor_username: str = Field(..., min_length=1)
supervisor_password: str = Field(..., min_length=1)
class TaskProgressResponse(BaseModel):
"""How far one measurement task has got: quotes taken out of quotes expected."""
task_id: int
quotes: int
measured: int
# empty (declared as measurement, no quotes yet) | none | partial | complete
state: str
class TaskProgressListResponse(BaseModel):
"""Progress of every measurement task of a version."""
tasks: list[TaskProgressResponse]
class MeasurementQuery(BaseModel): class MeasurementQuery(BaseModel):
"""Schema for measurement query filters.""" """Schema for measurement query filters."""
recipe_id: Optional[int] = None recipe_id: Optional[int] = None
+89
View File
@@ -0,0 +1,89 @@
"""Pydantic schemas for production runs and their events."""
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
class ProductionRunCreate(BaseModel):
station_code: str = Field(..., min_length=1, max_length=100)
recipe_id: int = Field(..., gt=0)
version_id: Optional[int] = Field(default=None, gt=0)
lot_number: Optional[str] = Field(default=None, max_length=100)
serial_number: Optional[str] = Field(default=None, max_length=100)
class ProductionEventResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
event_type: str
user_id: int
supervisor_id: Optional[int]
task_id: Optional[int] = None
note: Optional[str]
created_at: datetime
class ProductionRunResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
station_id: int
recipe_id: int
version_id: int
operator_id: int
lot_number: Optional[str]
serial_number: Optional[str]
status: str
measurement_interval_minutes: Optional[int]
next_measurement_at: Optional[datetime]
cycle_count: int
started_at: datetime
paused_at: Optional[datetime]
closed_at: Optional[datetime]
closed_by: Optional[int]
statistics_path: Optional[str] = None
# Derived server-side so every client agrees on the countdown regardless of
# clock skew. Negative once the interval has elapsed: how long the run has been
# overdue is a fact the operator must see, not deduce.
seconds_to_next_measurement: Optional[int] = None
overdue: bool = False
server_time: Optional[datetime] = None
# The measurement tasks of this run's version, in execution order. The client
# needs them to bring the operator back to the measurement when the interval
# expires, whichever screen they are on, and to know which task closes a cycle.
measurement_task_ids: list[int] = Field(default_factory=list)
class ProductionRunWithEventsResponse(ProductionRunResponse):
events: list[ProductionEventResponse] = Field(default_factory=list)
class SupervisorAction(BaseModel):
"""Credentials of the supervisor authorising a line stop or a close."""
supervisor_username: str = Field(..., min_length=1)
supervisor_password: str = Field(..., min_length=1)
note: Optional[str] = None
class CycleCompletePayload(BaseModel):
# Which measurement task was just finished. The interval restarts only on the
# last one of the recipe; omitting it closes the cycle outright.
task_id: Optional[int] = Field(default=None, gt=0)
note: Optional[str] = None
class RemeasurePayload(BaseModel):
"""The piece was turned over: measure it again inside the same cycle."""
task_id: Optional[int] = Field(default=None, gt=0)
note: Optional[str] = None
ProductionEventType = Literal[
"start", "cycle_completed", "task_measured", "remeasure",
"line_stop", "resume", "close",
]
@@ -5,7 +5,7 @@ from typing import Optional, TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
if TYPE_CHECKING: if TYPE_CHECKING:
from schemas.task import TaskResponse from src.backend.models.api.task import TaskResponse
class RecipeCreate(BaseModel): class RecipeCreate(BaseModel):
@@ -14,6 +14,12 @@ class RecipeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
image_path: Optional[str] = Field(None, max_length=500) image_path: Optional[str] = Field(None, max_length=500)
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
# Rules of the recipe: what the operator must supply, and how a value may be
# entered. Manual input defaults to forbidden - the caliper is the instrument.
requires_lot: bool = False
requires_serial: bool = False
allow_manual_input: bool = False
# Optional task-level fields for the initial technical drawing # Optional task-level fields for the initial technical drawing
file_path: Optional[str] = Field(None, max_length=500) file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
@@ -25,6 +31,10 @@ class RecipeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255) name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
image_path: Optional[str] = Field(None, max_length=500) image_path: Optional[str] = Field(None, max_length=500)
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
requires_lot: Optional[bool] = None
requires_serial: Optional[bool] = None
allow_manual_input: Optional[bool] = None
change_notes: Optional[str] = None change_notes: Optional[str] = None
# Task-level fields: saved to the first task of the new version # Task-level fields: saved to the first task of the new version
file_path: Optional[str] = Field(None, max_length=500) file_path: Optional[str] = Field(None, max_length=500)
@@ -55,6 +65,12 @@ class RecipeResponse(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
image_path: Optional[str] = None image_path: Optional[str] = None
measurement_interval_minutes: Optional[int] = None
# The operator's screen reads these to know whether to ask for lot and serial
# before starting, and whether to offer the keypad at all.
requires_lot: bool = False
requires_serial: bool = False
allow_manual_input: bool = False
created_by: int created_by: int
created_at: datetime created_at: datetime
active: bool active: bool
@@ -71,6 +87,6 @@ class RecipeListResponse(BaseModel):
# Forward reference imports for model_rebuild # Forward reference imports for model_rebuild
from schemas.task import TaskResponse # noqa: E402 from src.backend.models.api.task import TaskResponse # noqa: E402
RecipeVersionResponse.model_rebuild() RecipeVersionResponse.model_rebuild()
RecipeResponse.model_rebuild() RecipeResponse.model_rebuild()
@@ -51,7 +51,20 @@ class RecipeSummary(BaseModel):
code: str code: str
name: str name: str
active: bool active: bool
image_path: Optional[str] = None
description: Optional[str] = None
# The selection screen has to know before the operator starts: what a recipe
# demands is the difference between an enabled Avvia and a disabled one.
requires_lot: bool = False
requires_serial: bool = False
class StationWithRecipesResponse(StationResponse): class StationWithRecipesResponse(StationResponse):
recipes: list[RecipeSummary] = Field(default_factory=list) recipes: list[RecipeSummary] = Field(default_factory=list)
class StationResetResponse(BaseModel):
"""Outcome of clearing every recipe assignment of a station."""
station_id: int
removed: int

Some files were not shown because too many files have changed in this diff Show More