54 Commits

Author SHA1 Message Date
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
226 changed files with 19559 additions and 7430 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/
+13 -1
View File
@@ -24,14 +24,26 @@ 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
# --- 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
+15 -11
View File
@@ -33,21 +33,24 @@ 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 !uploads/images/.gitkeep
!server/uploads/logos/.gitkeep !uploads/pdfs/.gitkeep
!server/uploads/reports/.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 +66,11 @@ 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
+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='*'"]
+42
View File
@@ -0,0 +1,42 @@
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).
WORKDIR /app/flask_app
RUN npm install tailwindcss@3 && \
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()"]
+169 -93
View File
@@ -1,6 +1,9 @@
# 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:** V2.0.0 (in sviluppo) — branch default `V2.0.0`.
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
--- ---
@@ -12,10 +15,13 @@ 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)
--- ---
@@ -26,25 +32,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 +60,47 @@ 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.x (CDN) | Reattività leggera lato client |
| TailwindCSS | 3.x | CSS utility-first | | TailwindCSS | 3.x | CSS utility-first |
| Plotly.js | CDN | Grafici SPC interattivi | | Plotly.js | CDN | Grafici SPC interattivi |
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici | | Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
| html5-qrcode | CDN | Scanner barcode/QR camera |
| Flask-Babel | ultima stabile | i18n IT/EN | | Flask-Babel | ultima stabile | i18n IT/EN |
### Tooling
| Componente | Ruolo |
|---|---|
| **uv** | Package manager Python (no `requirements.txt`) |
| `pyproject.toml` | Dipendenze monorepo con extra `server`/`client`/`dev` |
| `uv.lock` | Lockfile per build riproducibili |
| `.python-version` | Pin Python 3.11 |
| pytest + pytest-asyncio + httpx + aiosqlite | Test stack |
--- ---
## Quick Start con Docker ## 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 +108,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 +116,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 +128,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 +143,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 +162,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 +210,9 @@ 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 | | **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account | | **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
--- ---
@@ -195,39 +220,53 @@ 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,
│ │ # SystemSetting, RecipeVersionAudit
│ ├── schemas/ # Pydantic v2 per validazione I/O API
│ ├── routers/ # auth, users, recipes, tasks, measurements,
│ │ # files, settings, statistics, reports, setup
│ ├── services/ # recipe_service, measurement_service,
│ │ # spc_service, report_service, auth_service
│ ├── middleware/ # api_key, rate_limit, security_headers, logging
│ ├── migrations/ # Alembic (alembic.ini + env.py)
│ └── templates/ # Template HTML pagina setup
├── client/ # Flask Frontend
│ ├── app.py # Factory pattern, CSRF, Babel
│ ├── blueprints/ # auth, maker, measure, statistics, admin
│ ├── services/ # APIClient singleton (proxy verso FastAPI)
│ ├── templates/ # Jinja2 + Alpine.js
│ ├── static/
│ │ ├── css/ # TailwindCSS compilato
│ │ └── js/ # numpad, caliper, barcode, csv-export,
│ │ # spc-charts, annotation-editor/viewer
│ └── translations/ # Flask-Babel .po/.mo IT/EN
├── nginx/ # Configurazione Nginx (dev)
├── docs/ # Documentazione tecnica
│ ├── API.md # Riferimento API REST
│ ├── DEPLOYMENT.md # Guida deployment VPS (Traefik, SSL, DNS)
│ └── USER_GUIDE.md # Manuale utente per ruolo
├── docker-compose.yml # Produzione (Traefik, SSL)
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80) ├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
── .env.example # Template variabili d'ambiente ── docker-compose.yml # Produzione (Traefik, SSL)
├── nginx/ # Config Nginx (dev)
├── uploads/ # Volume Docker file caricati
├── docs/ # Documentazione (vedi indice docs/README.md)
└── src/
├── backend/ # FastAPI Backend
│ ├── main.py # Entry point, lifespan, middleware, 11 router
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
│ ├── database.py # SQLAlchemy 2.0 async engine
│ ├── api/
│ │ ├── routers/ # auth, users, recipes, tasks, measurements,
│ │ │ # files, settings, statistics, reports,
│ │ │ # setup, stations
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
│ ├── models/
│ │ ├── orm/ # SQLAlchemy: User, Recipe, RecipeVersion,
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
│ │ │ # AccessLog, SystemSetting,
│ │ │ # RecipeVersionAudit, Station,
│ │ │ # StationRecipeAssignment
│ │ └── api/ # Pydantic v2 schemas request/response
│ ├── services/ # recipe_service, measurement_service,
│ │ # spc_service, report_service,
│ │ # auth_service, station_service
│ ├── migrations/ # Alembic (alembic.ini + env.py)
│ ├── templates/ # Pagina setup (Jinja2)
│ └── tests/ # pytest + httpx + aiosqlite
└── frontend/
└── flask_app/ # Flask Frontend
├── app.py # Factory + ProxyFix + CSRF + Babel
├── config.py # STATION_CODE, API_SERVER_URL, ecc.
├── compile_translations.py
├── blueprints/ # auth, maker, measure, statistics, admin
├── services/ # APIClient (proxy verso FastAPI con XFF)
├── templates/ # Jinja2 + Alpine.js
├── static/
│ ├── css/ # TailwindCSS compilato
│ └── js/ # numpad, caliper, barcode, csv-export,
│ # spc-charts, annotation-editor/viewer
├── translations/ # Flask-Babel .po/.mo IT/EN
└── tests/
``` ```
--- ---
@@ -238,7 +277,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 +287,62 @@ 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/
``` ```
Stato corrente: **183 pass, 0 fail** (vedi `docs/architecture/STATO_PROGETTO.md`).
--- ---
## Variabili d'Ambiente ## Variabili d'Ambiente
@@ -304,8 +358,11 @@ 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 | | `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 +371,35 @@ 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) | | [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall | | [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
### Riferimenti operativi
| Documento | Contenuto |
|---|---|
| [`docs/API.md`](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
### Piani dettagliati
| Documento | Contenuto |
|---|---|
| [`docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md`](docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 (M1 + M2, decisioni aperte, stime) |
| [`docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md`](docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD Fase 1 stazioni (completato) |
--- ---
## Licenza ## 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()"]
-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 %}
@@ -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())
+3 -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
@@ -43,8 +43,8 @@ services:
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:
+4 -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
@@ -52,8 +52,8 @@ services:
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 +93,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**.
+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
``` ```
+43
View File
@@ -0,0 +1,43 @@
# Documentazione TieMeasureFlow
Indice della documentazione del progetto.
## Stato e direzione
| Documento | Scopo |
|---|---|
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V2.0.0). Snapshot del sistema, componenti e capacità. |
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta da fare. Fasi 2-7 della migrazione rev04 verso V1.1.0/M1 demo cliente. |
## Riferimenti operativi
| Documento | Scopo |
|---|---|
| [`API.md`](API.md) | Riferimento endpoint REST esposti dal backend FastAPI. |
| [`DEPLOYMENT.md`](DEPLOYMENT.md) | Guida deploy su VPS (Hostinger/Tielogic con Traefik + Let's Encrypt). |
| [`USER_GUIDE.md`](USER_GUIDE.md) | Manuale utente (operatore, maker, metrologist, admin). |
| [`I18N_SETUP.md`](I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js). |
## Piani dettagliati TDD (rev04)
| Documento | Scopo |
|---|---|
| [`superpowers/plans/2026-04-17-rev04-master-roadmap.md`](superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 V1.0.7 → V1.1.0, le 7 fasi e le decisioni aperte. |
| [`superpowers/plans/2026-04-17-rev04-phase1-stations.md`](superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD dettagliato Fase 1 (stazioni e identità per-tablet). **COMPLETATO.** |
## Specifiche esterne (binari)
| Documento | Scopo |
|---|---|
| [`specs/2026-04-16-schema-sviluppo-rev04.docx`](specs/2026-04-16-schema-sviluppo-rev04.docx) | Schema sviluppo SW TieFlow rev04-2026 fornito dal committente. |
## Storico
| Documento | Scopo |
|---|---|
| [`archive/2026-02-06-piano-implementazione-v1.md`](archive/2026-02-06-piano-implementazione-v1.md) | Piano implementazione V1.0.0 originale (riferimento storico). |
## Ulteriori riferimenti nel repo
- [`/CLAUDE.md`](../CLAUDE.md) — guidance per Claude Code (architettura, comandi, pattern critici).
- [`/README.md`](../README.md) — entry point del progetto.
+89 -3
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
+95
View File
@@ -0,0 +1,95 @@
# Roadmap TieMeasureFlow — V2.0.0 → V1.1.0 (rev04 / M1 demo cliente)
> Aggiornare ad ogni Fase chiusa.
## Riferimenti
- Master plan dettagliato: [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md)
- Spec sorgente: [`../specs/2026-04-16-schema-sviluppo-rev04.docx`](../specs/2026-04-16-schema-sviluppo-rev04.docx)
- Stato corrente: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
## Strategia: due milestone
| Milestone | Scope | Obiettivo |
|---|---|---|
| **M1 — Demo cliente** | Fasi 1-5 + deploy "demo" | Sistema testabile end-to-end col cliente per raccogliere feedback |
| **M2 — Produzione** | Fasi 6-7 + correzioni post-feedback + GAIA live | Rollout su tablet/PC reali |
## Stato Fasi (M1)
| Fase | Scope | Stato | Branch / Commit |
|---|---|---|---|
| **1** | Stazioni + identità per-tablet | ✅ **COMPLETATA** | `V2.0.0` (merge `ea8e468` da `feature/rev04-phase1-stations`) |
| 2 | Ruolo Capoturno (Supervisor) + override token breve | ⏳ Da iniziare | — |
| 3 | Editor ricetta a blocchi (preparation + measurement) | ⏳ Da iniziare | — |
| 4 | Workflow operatore (retry/timer/autologout/avvio produzione) | ⏳ Da iniziare | — |
| 5 (M1) | `ImportOnlyGaiaClient` + UI import dati cliente reali | ⏳ Da iniziare | — |
| Deploy M1 | VPS demo (compose + Traefik + LE, no registry) | ⏳ Da iniziare | — |
## Stato Fasi (M2)
| Fase | Scope | Stato |
|---|---|---|
| 5 (M2) | GAIA reale (protocollo TBD, polling, comandi produzione) | ⏳ Bloccata da decisioni cliente (D-0.1, D-0.2) |
| 6 | Deploy B industriale (registry privato + Watchtower + STATION_ID per-tablet + CI release) | ⏳ Pianificata |
| 7 | Hardening, security review, E2E sito pilota, docs aggiornati, i18n delta | ⏳ Pianificata |
## Decisioni aperte (bloccanti per M2 / future fasi)
Da: master plan §0 "Precondizioni e Decisioni Aperte". Da risolvere col cliente prima della Fase 5/6.
| ID | Decisione | Stato | Bloccante per |
|---|---|---|---|
| D-0.1 | Protocollo integrazione GAIA (REST / DB shared / OPC-UA / file) | **Aperta** | Fase 5 reale (M2) |
| D-0.2 | Credenziali e rete GAIA (VPN / firewall / whitelist IP) | **Aperta** | Fase 5 reale (M2) |
| D-0.3 | Target hardware "tablet" (Windows / Linux industriale / Android) | **Aperta** | Fase 6 (deploy B) |
| D-0.4 | Cicalino/luce avviso (audio HTML5 / hardware USB / entrambi) | **Rimandata a M2** | Fase 4 finale |
| D-0.5 | Parametri runtime modificabili vs versione immutabile | **Aperta** (raccomandato B: separare volatili) | Fase 3 |
| D-0.6 | Auth capoturno durante override (modale / PIN / RFID) | **Aperta** | Fase 2 |
| D-0.7 | Timeout auto-logout | **Risolta** | — |
| D-0.8 | Naming ruolo capoturno | **Proposta:** `Supervisor` | Fase 2 |
| D-0.9 | Tag versione immagine docker | **Proposta:** SemVer + `latest` | Fase 6 |
| D-0.10 | Registry esposto su Internet o solo VPN | **Proposta:** solo VPN cliente | Fase 6 |
## Tech debt da chiudere
| Item | Priorità | Note |
|---|---|---|
| 3 test backend pre-esistenti rotti (`test_recipes`, `test_tasks`) | Media | Investigare prima di Fase 3 (toccano recipe + task router). |
| 1 test client pre-esistente rotto (`test_save_measurement_proxy`) | Bassa | Probabilmente CSRF/payload. Risolvere con Fase 4. |
| Pagina `task_complete` riepilogo: utente segnala riga vuota in alcuni scenari | Media | Da debuggare (rendering corretto via curl ma utente vede vuoto in browser, possibile interazione con sessione lot/serial). |
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato (impatto deploy). |
| Header `X-API-Key` rename a `X-Api-Key` | Bassa | Vedere se M2 lo richiede. |
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 in M2. |
| `Dockerfile.frontend`: `pybabel compile` via `uv run` non testato in build reale | Alta | Verificare al primo `docker compose build`. |
| Smoke test in container Docker (non solo locale uvicorn+gunicorn) | Alta | Validare che i Dockerfile riscritti con `uv` buildino e girino correttamente prima di chiudere V2.0.0. |
## Open per scelta utente prima della prossima sessione
1. **Quale fase iniziare adesso?** Opzioni: Fase 2 (Supervisor — sblocca workflow), Fase 3 (block editor — independente), Fase 5 (import GAIA dati reali — sblocca demo).
2. **Revisione decisioni aperte col cliente** — D-0.1 / D-0.2 / D-0.3 / D-0.6 prima di pianificare Fase 5 e 6.
3. **Smoke test Docker** della nuova struttura V2.0.0 (`docker compose -f docker-compose.dev.yml up --build`) per validare i Dockerfile riscritti.
4. **Test di carico** (k6/locust) a 20 VU su `/measure/save-measurement` per validare la scalatura worker (capacità annunciata: 20-30 tablet contemporanei).
## Stima tempi residui M1 (post-Fase 1)
| Task | Stima full-time |
|---|---|
| Fase 2 — Supervisor + override | 1 settimana |
| Fase 3 — Block editor | 1.5 settimane |
| Fase 4 — Workflow operatore | 2 settimane |
| Fase 5 (M1) — Import-only GAIA | 1 settimana |
| Deploy M1 demo | 0.5 settimane |
| **Totale M1 residuo** | **~6 settimane** |
## Stima tempi M2 (dopo feedback)
| Task | Stima |
|---|---|
| Aggiustamenti post-feedback | variabile (1-2 sett.) |
| Fase 5 reale GAIA | 1-2 settimane |
| Fase 6 deploy B | 1 settimana |
| Fase 7 hardening | 1-2 settimane |
| **Totale M2** | **~4-7 settimane** |
**Totale fino a produzione:** ~10-13 settimane full-time da oggi (2026-04-25), assumendo decisioni aperte risolte in tempo utile.
+145
View File
@@ -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.
+75
View File
@@ -0,0 +1,75 @@
[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",
]
[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.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests"]
-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_*
@@ -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,23 @@ 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 (
MeasurementBatchCreate, MeasurementBatchCreate,
MeasurementCreate, MeasurementCreate,
MeasurementListResponse, MeasurementListResponse,
MeasurementResponse, MeasurementResponse,
) )
from services.measurement_service import save_measurement 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 +46,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:
@@ -74,6 +76,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 +88,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 +123,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 +218,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 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 production_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 production_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 production_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,6 +172,7 @@ 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,
@@ -223,7 +224,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()}
@@ -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)
+20 -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,10 @@ 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"
@property @property
def database_url(self) -> str: def database_url(self) -> str:
"""Async MySQL connection string.""" """Async MySQL connection string."""
@@ -49,10 +53,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(
+18 -16
View File
@@ -5,22 +5,23 @@ 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
@asynccontextmanager @asynccontextmanager
@@ -73,6 +74,7 @@ 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.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)
@@ -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,
@@ -13,6 +13,9 @@ class MeasurementCreate(BaseModel):
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)
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 +37,8 @@ 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
measured_at: datetime measured_at: datetime
synced_to_csv: bool synced_to_csv: bool
+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,7 @@ 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)
# 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 +26,7 @@ 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)
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 +57,7 @@ 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
created_by: int created_by: int
created_at: datetime created_at: datetime
active: bool active: bool
@@ -71,6 +74,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,16 @@ class RecipeSummary(BaseModel):
code: str code: str
name: str name: str
active: bool active: bool
image_path: Optional[str] = None
description: Optional[str] = None
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
@@ -1,8 +1,13 @@
"""Pydantic schemas for RecipeTask and RecipeSubtask operations.""" """Pydantic schemas for RecipeTask and RecipeSubtask operations."""
from typing import Any, Optional from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from src.backend.models.orm.task import TASK_TYPES
# Kept in step with the ORM enum so the two cannot drift apart.
TaskType = Literal[TASK_TYPES]
class SubtaskCreate(BaseModel): class SubtaskCreate(BaseModel):
"""Schema for creating a subtask.""" """Schema for creating a subtask."""
@@ -53,6 +58,7 @@ class SubtaskResponse(BaseModel):
class TaskCreate(BaseModel): class TaskCreate(BaseModel):
"""Schema for creating a task.""" """Schema for creating a task."""
title: str = Field(..., min_length=1, max_length=255) title: str = Field(..., min_length=1, max_length=255)
task_type: TaskType = "note"
directive: Optional[str] = None directive: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
file_path: Optional[str] = Field(None, max_length=500) file_path: Optional[str] = Field(None, max_length=500)
@@ -64,6 +70,7 @@ class TaskCreate(BaseModel):
class TaskUpdate(BaseModel): class TaskUpdate(BaseModel):
"""Schema for updating a task.""" """Schema for updating a task."""
title: Optional[str] = Field(None, min_length=1, max_length=255) title: Optional[str] = Field(None, min_length=1, max_length=255)
task_type: Optional[TaskType] = None
directive: Optional[str] = None directive: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
file_path: Optional[str] = Field(None, max_length=500) file_path: Optional[str] = Field(None, max_length=500)
@@ -79,6 +86,7 @@ class TaskResponse(BaseModel):
version_id: int version_id: int
recipe_id: Optional[int] = None recipe_id: Optional[int] = None
order_index: int order_index: int
task_type: str
title: str title: str
directive: Optional[str] = None directive: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
+25
View File
@@ -0,0 +1,25 @@
"""SQLAlchemy models for TieMeasureFlow."""
from src.backend.models.orm.user import User
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.access_log import AccessLog
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.models.orm.production import ProductionRun, ProductionEvent
__all__ = [
"User",
"Recipe",
"RecipeVersion",
"RecipeTask",
"RecipeSubtask",
"Measurement",
"AccessLog",
"SystemSetting",
"RecipeVersionAudit",
"Station",
"StationRecipeAssignment",
"ProductionRun",
"ProductionEvent",
]
@@ -5,7 +5,7 @@ from typing import Optional
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from database import Base from src.backend.database import Base
class AccessLog(Base): class AccessLog(Base):
@@ -8,7 +8,7 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from database import Base from src.backend.database import Base
class Measurement(Base): class Measurement(Base):
@@ -43,6 +43,22 @@ class Measurement(Base):
default="manual", default="manual",
) )
# Time spent by the operator entering this value (client-reported)
input_duration_ms: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True
)
# The production this was taken during, when there was one. Nullable: rows
# predating production runs, and measurements taken outside a production, have
# none. This is what makes "the measurements of this production" a query, which
# fine produzione needs to emit the statistics file.
production_run_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("production_runs.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Timestamp # Timestamp
measured_at: Mapped[datetime] = mapped_column( measured_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), index=True DateTime, nullable=False, server_default=func.now(), index=True
+166
View File
@@ -0,0 +1,166 @@
"""ProductionRun and ProductionEvent models.
A production run is what an operator opens when production starts and a supervisor
closes when it ends. It is the thing that outlives a page: before this, the timer,
the cycle count and the "production started" flag were Alpine variables in
task_execute.html, and navigating between tasks - a full page load - destroyed them.
The measurement deadline is stored as an absolute timestamp (next_measurement_at)
rather than a remaining count. Any client can then derive the countdown from it, get
the same answer, survive a reload, and tell how far *past* the interval it is by
letting the difference go negative.
With one station app installed per PC (see the install architecture of 28/07) the
database is the only place this state can live: the API keeps nothing in process
memory.
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import (
DateTime, Enum, ForeignKey, Index, Integer, SmallInteger, String, Text,
UniqueConstraint, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.backend.database import Base
if TYPE_CHECKING:
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.station import Station
RUN_STATUSES = ("running", "paused", "closed")
# task_measured marks a measurement task finished inside a cycle that is not over
# yet; only the last one produces cycle_completed and restarts the interval.
# remeasure is the piece being turned and measured again without closing the cycle.
EVENT_TYPES = (
"start", "cycle_completed", "task_measured", "remeasure",
"line_stop", "resume", "close",
)
class ProductionRun(Base):
__tablename__ = "production_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
station_id: Mapped[int] = mapped_column(
Integer, ForeignKey("stations.id"), nullable=False, index=True
)
recipe_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipes.id"), nullable=False, index=True
)
version_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
)
operator_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
# Traceability travels with the run instead of the Flask session, so it stays
# attached to every measurement taken during it.
lot_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
serial_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
status: Mapped[str] = mapped_column(
Enum(*RUN_STATUSES, name="production_run_status_enum"),
nullable=False,
default="running",
index=True,
)
# Snapshot of the recipe interval at start: editing the recipe mid-run must not
# move the deadline of a production already under way.
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(
SmallInteger, nullable=True
)
# When the next measurement falls due. Null when the recipe has no interval.
# Past this instant the run is overdue, and by how much is simply now - this.
next_measurement_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True, index=True
)
cycle_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
started_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
closed_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
# Statistics file emitted when the run was closed, relative to the upload dir.
statistics_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Mirrors station_id while the run is open and goes NULL when it closes. A unique
# index on it lets the database - not a check-then-insert race - guarantee that a
# station never has two open runs. Repeated NULLs do not collide in a unique index.
active_station_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
station: Mapped["Station"] = relationship(lazy="selectin")
recipe: Mapped["Recipe"] = relationship(lazy="selectin")
version: Mapped["RecipeVersion"] = relationship(lazy="selectin")
events: Mapped[list["ProductionEvent"]] = relationship(
back_populates="run",
cascade="all, delete-orphan",
order_by="ProductionEvent.created_at",
)
__table_args__ = (
UniqueConstraint("active_station_id", name="uq_production_runs_active_station"),
Index("ix_production_runs_station_status", "station_id", "status"),
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
)
@property
def is_open(self) -> bool:
return self.status in ("running", "paused")
def __repr__(self) -> str:
return f"<ProductionRun {self.id} station={self.station_id} {self.status}>"
class ProductionEvent(Base):
"""Append-only trace of what happened during a run.
This is the record that did not exist before: without it there is no history of
a production, and nothing for the statistics file or the ERP hand-off to stand on.
"""
__tablename__ = "production_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
run_id: Mapped[int] = mapped_column(
Integer, ForeignKey("production_runs.id", ondelete="CASCADE"),
nullable=False, index=True,
)
event_type: Mapped[str] = mapped_column(
Enum(*EVENT_TYPES, name="production_event_type_enum"), nullable=False, index=True
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
# Who authorised it, when the action needed a supervisor (fermo linea, chiusura).
supervisor_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
# Which task the event is about, for the ones that happen inside a cycle. A
# task_measured or a remeasure that does not say which task is not a record of
# anything; null for the events that concern the run as a whole.
task_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("recipe_tasks.id"), nullable=True
)
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), index=True
)
run: Mapped["ProductionRun"] = relationship(back_populates="events")
__table_args__ = (
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
)
def __repr__(self) -> str:
return f"<ProductionEvent run={self.run_id} {self.event_type}>"
@@ -2,13 +2,13 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, SmallInteger, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base from src.backend.database import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from models.task import RecipeTask from src.backend.models.orm.task import RecipeTask
class Recipe(Base): class Recipe(Base):
@@ -24,6 +24,7 @@ class Recipe(Base):
DateTime, nullable=False, server_default=func.now() DateTime, nullable=False, server_default=func.now()
) )
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
# Relationships # Relationships
versions: Mapped[list["RecipeVersion"]] = relationship( versions: Mapped[list["RecipeVersion"]] = relationship(
@@ -5,7 +5,7 @@ from typing import Optional
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from database import Base from src.backend.database import Base
class SystemSetting(Base): class SystemSetting(Base):
@@ -10,10 +10,10 @@ from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base from src.backend.database import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from models.recipe import Recipe from src.backend.models.orm.recipe import Recipe
class Station(Base): class Station(Base):
@@ -6,10 +6,19 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base from src.backend.database import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from models.recipe import RecipeVersion from src.backend.models.orm.recipe import RecipeVersion
# What a task is, declared by whoever writes the recipe instead of guessed from its
# contents. The last two are not built yet; they are in the enum from the start
# because widening a MySQL enum later is an ALTER on a live table.
TASK_TYPES = ("note", "measure", "drawing", "xf_compare", "camera_measure")
# Types the operator screen treats as "there are quotes to take here".
MEASURING_TASK_TYPES = ("measure", "camera_measure")
class RecipeTask(Base): class RecipeTask(Base):
@@ -20,6 +29,16 @@ class RecipeTask(Base):
Integer, ForeignKey("recipe_versions.id"), nullable=False Integer, ForeignKey("recipe_versions.id"), nullable=False
) )
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Declared, not deduced. The old rule - "has subtasks, therefore a measurement" -
# meant a measurement task whose quotes had not been entered yet behaved as a
# note, so the system acted differently depending on how finished the recipe was.
task_type: Mapped[str] = mapped_column(
Enum(*TASK_TYPES, name="task_type_enum"),
nullable=False,
default="note",
server_default="note",
index=True,
)
title: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False)
directive: Mapped[Optional[str]] = mapped_column(Text, nullable=True) directive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
@@ -5,7 +5,7 @@ from typing import Optional
from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from database import Base from src.backend.database import Base
class User(Base): class User(Base):
+74
View File
@@ -0,0 +1,74 @@
"""AI service for parsing technical sheets via OpenRouter."""
import json
import logging
from io import BytesIO
import httpx
import pdfplumber
from src.backend.config import settings
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = """Sei un assistente specializzato nell'analisi di schede tecniche industriali.
Analizza il testo estratto da una scheda tecnica e identifica i blocchi operativi distinti.
Per ogni blocco, estrai:
- title: titolo breve del blocco (es. "MATERIALI", "TEMPERATURA", "MISURA", "IMBALLO")
- directive: istruzione operativa principale (es. "VERIFICARE ATTREZZATURE")
- description: dettagli completi del blocco, preservando gli "a capo" originali
Rispondi SOLO con un array JSON valido, senza markdown o testo aggiuntivo.
Esempio:
[
{"title": "MATERIALI", "directive": "Verificare materiali", "description": "RIGIDO (36): AFT9/UV CRI 7\\nMORBIDO (E38): M70"},
{"title": "TEMPERATURA", "directive": "Verificare impostazioni", "description": "RIGIDO: TEMP: 170/170/170/170/170\\nMORBIDO: TEMP: 130-135-140-145"}
]"""
def extract_text_from_pdf(pdf_bytes: bytes) -> str:
with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
pages = []
for page in pdf.pages:
text = page.extract_text()
if text:
pages.append(text)
return "\n\n---\n\n".join(pages)
async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
text = extract_text_from_pdf(pdf_bytes)
if not text.strip():
return []
if not settings.openrouter_api_key:
raise ValueError("OPENROUTER_API_KEY non configurata")
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json",
},
json={
"model": settings.openrouter_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Analizza questa scheda tecnica ed estrai i task:\n\n{text}"},
],
"temperature": 0.1,
},
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"].strip()
# Strip markdown fences if present
if content.startswith("```"):
content = content.split("\n", 1)[1]
if content.endswith("```"):
content = content[:-3]
content = content.strip()
return json.loads(content)
@@ -6,7 +6,7 @@ import bcrypt
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User from src.backend.models.orm.user import User
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
@@ -4,8 +4,8 @@ from decimal import Decimal
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from models.task import RecipeSubtask from src.backend.models.orm.task import RecipeSubtask
def calculate_pass_fail( def calculate_pass_fail(
@@ -48,6 +48,8 @@ async def save_measurement(
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
input_method: str = "manual", input_method: str = "manual",
input_duration_ms: int | None = None,
production_run_id: int | None = None,
) -> Measurement: ) -> Measurement:
"""Save a single measurement with auto-calculated pass/fail.""" """Save a single measurement with auto-calculated pass/fail."""
# Get subtask for tolerance values # Get subtask for tolerance values
@@ -70,6 +72,8 @@ async def save_measurement(
lot_number=lot_number, lot_number=lot_number,
serial_number=serial_number, serial_number=serial_number,
input_method=input_method, input_method=input_method,
input_duration_ms=input_duration_ms,
production_run_id=production_run_id,
) )
db.add(measurement) db.add(measurement)
await db.flush() await db.flush()
@@ -0,0 +1,161 @@
"""Statistics file emitted when a production is closed.
Fine produzione has to hand the measurements of the whole production over. That part
does not depend on the ERP: the file is produced now, and the hand-off to GAIA plugs
in beside it once the protocol is agreed (questions D-1 and D-2). Keeping it in its
own module makes that seam obvious rather than buried in the close path.
The CSV uses the delimiters configured in system_settings, the same ones the manual
export honours, so a shop floor set to the Italian convention (';' and ',') gets
files that open correctly in their Excel.
"""
import csv
import io
from datetime import datetime
from pathlib import Path
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import Recipe
from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask
# Written under the upload directory: it is the volume that is already mounted and
# backed up, so the files survive a container being replaced.
EXPORT_SUBDIR = "statistics"
HEADER = [
"production_run_id",
"station_code",
"recipe_code",
"version_id",
"lot_number",
"serial_number",
"measurement_id",
"subtask_id",
"marker_number",
"subtask_description",
"nominal",
"value",
"deviation",
"pass_fail",
# The limits travel with the row: without them a pass/fail cannot be re-derived
# from the file years later, which is the whole point of an audit record.
"ltl",
"lwl",
"uwl",
"utl",
"unit",
"input_method",
"input_duration_ms",
"measured_by",
"measured_at",
]
async def _csv_format(db: AsyncSession) -> tuple[str, str]:
"""Delimiter and decimal separator as configured for this installation."""
delimiter_row = await db.execute(
select(SystemSetting).where(SystemSetting.setting_key == "csv_delimiter")
)
delimiter = delimiter_row.scalar_one_or_none()
decimal_row = await db.execute(
select(SystemSetting).where(
SystemSetting.setting_key == "csv_decimal_separator"
)
)
decimal = decimal_row.scalar_one_or_none()
return (
delimiter.setting_value if delimiter else ",",
decimal.setting_value if decimal else ".",
)
async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
"""Render every measurement of a run as CSV text."""
delimiter, decimal_separator = await _csv_format(db)
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
recipe = (await db.execute(
select(Recipe).where(Recipe.id == run.recipe_id)
)).scalar_one_or_none()
rows = (await db.execute(
select(Measurement, RecipeSubtask)
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id, isouter=True)
.where(Measurement.production_run_id == run.id)
.order_by(Measurement.measured_at, Measurement.id)
)).all()
def number(value) -> str:
return "" if value is None else str(value).replace(".", decimal_separator)
output = io.StringIO()
writer = csv.writer(output, delimiter=delimiter, lineterminator="\n")
writer.writerow(HEADER)
for measurement, subtask in rows:
writer.writerow([
run.id,
station.code if station else "",
recipe.code if recipe else "",
measurement.version_id,
measurement.lot_number or "",
measurement.serial_number or "",
measurement.id,
measurement.subtask_id,
subtask.marker_number if subtask else "",
subtask.description if subtask else "",
number(subtask.nominal) if subtask else "",
number(measurement.value),
number(measurement.deviation),
measurement.pass_fail,
number(subtask.ltl) if subtask else "",
number(subtask.lwl) if subtask else "",
number(subtask.uwl) if subtask else "",
number(subtask.utl) if subtask else "",
(subtask.unit or "") if subtask else "",
measurement.input_method,
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
measurement.measured_by,
measurement.measured_at.isoformat() if measurement.measured_at else "",
])
return output.getvalue()
async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | None:
"""Write the run's statistics file and mark its measurements as exported.
Returns the path relative to the upload directory, or None when the run has no
measurements - an empty file would only be noise in the folder.
"""
content = await build_statistics_csv(db, run)
# Header only: nothing was measured during this production.
if len(content.strip().splitlines()) <= 1:
return None
directory = Path(settings.upload_path) / EXPORT_SUBDIR
directory.mkdir(parents=True, exist_ok=True)
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
filename = f"production_{run.id}_{stamp}.csv"
(directory / filename).write_text(content, encoding="utf-8-sig")
# utf-8-sig: the BOM is what makes Excel read accented characters correctly,
# and these files are opened in Excel on the shop floor.
await db.execute(
update(Measurement)
.where(Measurement.production_run_id == run.id)
.values(synced_to_csv=True)
)
return f"{EXPORT_SUBDIR}/{filename}"
+438
View File
@@ -0,0 +1,438 @@
"""Business logic for production runs.
Routers must call into these functions rather than manipulating models directly.
All functions are async and accept an AsyncSession; they flush but do NOT commit
(commit is handled by the FastAPI get_db dependency).
Clock convention
----------------
Every timestamp this module writes comes from ``_now()``, so the deadline and the
comparison against it are always on the same clock. Clients are handed
``seconds_to_next_measurement`` already computed rather than the raw deadline: a
naive datetime crossing the wire would be parsed in the browser's own timezone, and
the countdown would be off by the UTC offset. The number is the contract; the
timestamp is only there for the audit trail.
"""
from datetime import datetime, timedelta
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.api.production import ProductionRunCreate
from src.backend.models.orm.production import ProductionEvent, ProductionRun
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import MEASURING_TASK_TYPES, RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import auth_service, production_export_service
def _now() -> datetime:
"""Single source of time for runs.
Naive local time, matching what the database writes for ``func.now()`` defaults,
so a run's started_at and its deadline are comparable.
"""
return datetime.now()
# ---------------------------------------------------------------------------
# Derived view
# ---------------------------------------------------------------------------
def seconds_to_next_measurement(run: ProductionRun, now: Optional[datetime] = None) -> Optional[int]:
"""Seconds until the next measurement is due; negative once it is overdue.
Letting the value go negative is deliberate: the operator has to see how long
the line has been past the interval, not merely that it elapsed.
While the run is paused the countdown is frozen at what was left when the line
stopped - a fermo linea must not eat into the measurement interval.
"""
if run.next_measurement_at is None:
return None
reference = run.paused_at if (run.status == "paused" and run.paused_at) else (now or _now())
return int(round((run.next_measurement_at - reference).total_seconds()))
def describe(run: ProductionRun) -> dict:
"""Fields the API adds on top of the stored columns."""
now = _now()
remaining = seconds_to_next_measurement(run, now)
return {
"seconds_to_next_measurement": remaining,
"overdue": remaining is not None and remaining < 0,
"server_time": now,
}
# ---------------------------------------------------------------------------
# Lookups
# ---------------------------------------------------------------------------
async def get_run(db: AsyncSession, run_id: int) -> ProductionRun:
result = await db.execute(select(ProductionRun).where(ProductionRun.id == run_id))
run = result.scalar_one_or_none()
if run is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found",
)
return run
async def get_open_run_for_station(
db: AsyncSession, station_id: int,
) -> Optional[ProductionRun]:
"""The run currently open at a station, running or paused."""
result = await db.execute(
select(ProductionRun).where(ProductionRun.active_station_id == station_id)
)
return result.scalar_one_or_none()
async def get_station_by_code(db: AsyncSession, code: str) -> Station:
result = await db.execute(select(Station).where(Station.code == code))
station = result.scalar_one_or_none()
if station is None or not station.active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Station '{code}' not found or inactive",
)
return station
async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
"""Ids of the measurement tasks of a version, in the order they are executed.
This is the loop the operator lives in once production starts: the client uses
the first of these to bring the measurement back on screen when the interval
expires, and the last to know which task closes a cycle.
The type is read from the declared task_type (point 2), not from whether quotes
happen to be present: a measurement task with no quotes yet is still where the
operator has to be taken.
"""
result = await db.execute(
select(RecipeTask.id)
.where(
RecipeTask.version_id == version_id,
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
)
.order_by(RecipeTask.order_index, RecipeTask.id)
)
return list(result.scalars().all())
async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent]:
result = await db.execute(
select(ProductionEvent)
.where(ProductionEvent.run_id == run_id)
.order_by(ProductionEvent.created_at, ProductionEvent.id)
)
return list(result.scalars().all())
# ---------------------------------------------------------------------------
# Supervisor authorisation
# ---------------------------------------------------------------------------
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
"""Check the credentials of the supervisor authorising a stop or a close.
Uses authenticate_user rather than a full login: logging in would rotate the
supervisor's API key and knock out whatever session they have open elsewhere.
"""
supervisor = await auth_service.authenticate_user(db, username, password)
if supervisor is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid supervisor credentials",
)
if not supervisor.has_role("Supervisor") and not supervisor.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is not authorised as supervisor",
)
return supervisor
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
def _add_event(
db: AsyncSession,
run: ProductionRun,
event_type: str,
user: User,
supervisor: Optional[User] = None,
note: Optional[str] = None,
task_id: Optional[int] = None,
) -> ProductionEvent:
"""Append to the run's trace.
Added through the session rather than run.events: touching the collection would
trigger a lazy load, which raises under async SQLAlchemy.
"""
event = ProductionEvent(
run_id=run.id,
event_type=event_type,
user_id=user.id,
supervisor_id=supervisor.id if supervisor else None,
note=note,
task_id=task_id,
created_at=_now(),
)
db.add(event)
return event
async def open_run(
db: AsyncSession, data: ProductionRunCreate, operator: User,
) -> ProductionRun:
"""Start a production at a station.
Refuses if the station already has one open: two runs on the same station would
mean two timers and two histories for one physical line.
"""
station = await get_station_by_code(db, data.station_code)
existing = await get_open_run_for_station(db, station.id)
if existing is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Station '{station.code}' already has an open production run",
)
recipe_row = await db.execute(select(Recipe).where(Recipe.id == data.recipe_id))
recipe = recipe_row.scalar_one_or_none()
if recipe is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
)
if data.version_id is not None:
version_row = await db.execute(
select(RecipeVersion).where(
RecipeVersion.id == data.version_id,
RecipeVersion.recipe_id == recipe.id,
)
)
version = version_row.scalar_one_or_none()
if version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Recipe version not found for this recipe",
)
else:
version_row = await db.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe.id,
RecipeVersion.is_current == True, # noqa: E712
)
)
version = version_row.scalar_one_or_none()
if version is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Recipe has no current version",
)
now = _now()
# The interval is copied, not referenced: editing the recipe mid-production must
# not move the deadline of a run already under way.
interval = recipe.measurement_interval_minutes
run = ProductionRun(
station_id=station.id,
recipe_id=recipe.id,
version_id=version.id,
operator_id=operator.id,
lot_number=data.lot_number,
serial_number=data.serial_number,
status="running",
measurement_interval_minutes=interval,
next_measurement_at=(
now + timedelta(minutes=interval) if interval and interval > 0 else None
),
cycle_count=0,
started_at=now,
active_station_id=station.id,
)
db.add(run)
await db.flush()
_add_event(db, run, "start", operator)
await db.flush()
await db.refresh(run)
return run
def _require_open(run: ProductionRun) -> None:
if run.status == "closed":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Production run is already closed",
)
def _require_measurable(run: ProductionRun) -> None:
_require_open(run)
if run.status == "paused":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Production run is paused: resume it before measuring",
)
async def complete_cycle(
db: AsyncSession,
run: ProductionRun,
user: User,
task_id: Optional[int] = None,
note: Optional[str] = None,
) -> ProductionRun:
"""Record a finished measurement task, and restart the interval if it was the last.
A cycle spans every measurement task of the recipe, so only the last one closes
it. Restarting the interval at the first would make a recipe with three
measurement tasks measure three times as often as it was set up to.
An unrecognised task_id - or none at all - closes the cycle: a caller that does
not know about the sequence gets the behaviour that existed before it.
"""
_require_measurable(run)
measure_ids = await measurement_task_ids(db, run.version_id)
closes_cycle = (
task_id is None or task_id not in measure_ids or task_id == measure_ids[-1]
)
if not closes_cycle:
_add_event(db, run, "task_measured", user, note=note, task_id=task_id)
await db.flush()
await db.refresh(run)
return run
now = _now()
run.cycle_count += 1
if run.measurement_interval_minutes and run.measurement_interval_minutes > 0:
# Measured from now, not from the previous deadline: a late measurement must
# not compress the following interval.
run.next_measurement_at = now + timedelta(
minutes=run.measurement_interval_minutes
)
_add_event(db, run, "cycle_completed", user, note=note, task_id=task_id)
await db.flush()
await db.refresh(run)
return run
async def remeasure(
db: AsyncSession,
run: ProductionRun,
user: User,
task_id: Optional[int] = None,
note: Optional[str] = None,
) -> ProductionRun:
"""Measure the same task again without closing the cycle.
This is the piece being turned over. The deadline and the cycle count are left
exactly as they are - a second reading of the same part is not a new cycle, and
it must not buy the operator another interval's worth of time. The values
themselves are ordinary measurements: both readings stay in the statistics,
which is the point of taking them.
"""
_require_measurable(run)
_add_event(db, run, "remeasure", user, note=note, task_id=task_id)
await db.flush()
await db.refresh(run)
return run
async def pause_run(
db: AsyncSession,
run: ProductionRun,
user: User,
supervisor: User,
note: Optional[str] = None,
) -> ProductionRun:
"""Fermo linea: suspend the run and freeze the countdown."""
_require_open(run)
if run.status == "paused":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Production run is already paused",
)
run.status = "paused"
run.paused_at = _now()
_add_event(db, run, "line_stop", user, supervisor=supervisor, note=note)
await db.flush()
await db.refresh(run)
return run
async def resume_run(
db: AsyncSession,
run: ProductionRun,
user: User,
supervisor: User,
note: Optional[str] = None,
) -> ProductionRun:
"""Restart a paused run, pushing the deadline out by the length of the stop."""
_require_open(run)
if run.status != "paused":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Production run is not paused",
)
now = _now()
if run.next_measurement_at is not None and run.paused_at is not None:
# Shift rather than recompute: whatever was left when the line stopped is
# what the operator gets back, so a stop neither grants nor costs time.
run.next_measurement_at = run.next_measurement_at + (now - run.paused_at)
run.status = "running"
run.paused_at = None
_add_event(db, run, "resume", user, supervisor=supervisor, note=note)
await db.flush()
await db.refresh(run)
return run
async def close_run(
db: AsyncSession,
run: ProductionRun,
user: User,
supervisor: User,
note: Optional[str] = None,
) -> ProductionRun:
"""Fine produzione: close the run for good, stop the timer, emit the statistics.
Everything here happens without the ERP. Handing the production over to GAIA
goes at the marked seam below, once D-1 and D-2 are answered; until then the
file is produced and the run is properly closed, which is what the shop floor
needs either way.
"""
_require_open(run)
run.status = "closed"
run.closed_at = _now()
run.closed_by = supervisor.id
run.next_measurement_at = None
run.paused_at = None
# Releasing the slot lets the station open a new run; the unique index on this
# column is what keeps exactly one open at a time.
run.active_station_id = None
_add_event(db, run, "close", user, supervisor=supervisor, note=note)
await db.flush()
# Statistics file for the whole production, and the measurements marked as sent.
run.statistics_path = await production_export_service.export_run_statistics(db, run)
# --- ERP hand-off (GAIA) plugs in here once the protocol is defined (D-1, D-2).
# Deliberately absent rather than stubbed: an empty call that looks wired is
# worse than none at all.
await db.flush()
await db.refresh(run)
return run
@@ -7,12 +7,12 @@ from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from models.recipe import Recipe, RecipeVersion from src.backend.models.orm.recipe import Recipe, RecipeVersion
from models.setting import RecipeVersionAudit from src.backend.models.orm.setting import RecipeVersionAudit
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.recipe import RecipeCreate, RecipeUpdate from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -55,6 +55,9 @@ async def _copy_tasks_to_version(
new_task = RecipeTask( new_task = RecipeTask(
version_id=target_version.id, version_id=target_version.id,
order_index=task.order_index, order_index=task.order_index,
# Carried over explicitly: a copy-on-write version that dropped the type
# would silently reclassify every task the moment a recipe is edited.
task_type=task.task_type,
title=task.title, title=task.title,
directive=task.directive, directive=task.directive,
description=task.description, description=task.description,
@@ -134,6 +137,7 @@ async def create_recipe(
name=data.name, name=data.name,
description=data.description, description=data.description,
image_path=data.image_path, image_path=data.image_path,
measurement_interval_minutes=data.measurement_interval_minutes,
created_by=user.id, created_by=user.id,
) )
db.add(recipe) db.add(recipe)
@@ -253,6 +257,7 @@ async def create_new_version(
default_task = RecipeTask( default_task = RecipeTask(
version_id=new_version.id, version_id=new_version.id,
order_index=0, order_index=0,
task_type="drawing",
title="Technical Drawing", title="Technical Drawing",
file_path=data.file_path, file_path=data.file_path,
file_type=data.file_type or ( file_type=data.file_type or (
@@ -273,6 +278,8 @@ async def create_new_version(
update_fields["description"] = data.description update_fields["description"] = data.description
if data.image_path is not None: if data.image_path is not None:
update_fields["image_path"] = data.image_path update_fields["image_path"] = data.image_path
if data.measurement_interval_minutes is not None:
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
if update_fields: if update_fields:
await db.execute( await db.execute(
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields) update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
@@ -369,6 +376,8 @@ async def update_current_version(
update_fields["description"] = data.description update_fields["description"] = data.description
if data.image_path is not None: if data.image_path is not None:
update_fields["image_path"] = data.image_path update_fields["image_path"] = data.image_path
if data.measurement_interval_minutes is not None:
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
if update_fields: if update_fields:
await db.execute( await db.execute(
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields) update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
@@ -10,13 +10,13 @@ from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from weasyprint import HTML from weasyprint import HTML
from config import settings from src.backend.config import settings
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from models.recipe import Recipe, RecipeVersion from src.backend.models.orm.recipe import Recipe, RecipeVersion
from models.setting import SystemSetting from src.backend.models.orm.setting import SystemSetting
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 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,

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