Compare commits

...

360 Commits

Author SHA1 Message Date
Adriano 8c361c14ae Visore <tmf-viewer> di TieVision-Station con no-layers su ogni immagine di TMF (#126)
Rotella senza Ctrl = zoom sul punto del mouse su tutte le immagini. image_viewer.js e
vision_overlay.js copiati byte per byte da TieVision-Station (lavoro/daitarn-nolayers b1af911);
attributo no-layers su ogni <tmf-viewer> (task_editor 3, task_execute 1, calibrazione_pagina 4).
Test JS e pagina demo allineati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-29 13:49:39 +01:00
Adriano 5897a9e5df Wizard: chiusura in più nella Prova tolta, percentuale del DXF rispetto ad Adatta (Refs #122)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 22:15:55 +01:00
Adriano f44b578d5b Unisce lavoro/daitarn-tmf-rev118: correzioni della revisione Fable (Refs #118 #123 #124 #125)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 22:07:44 +01:00
Adriano c5be9a2bb4 tmf-viewer: host [hidden] nascosto, wizard usa viewer-error al posto della sonda, test_dxf_preview senza zoom del wizard (Refs #122)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 22:07:36 +01:00
Adriano 17ea010f7e Test: quota NaN chiede il capoturno, migrazione 022 conserva le righe
Refs #125

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:07:18 +01:00
Adriano 1d6d74b2e4 Unisce lavoro/daitarn-tmf-v-camera: visore standard <tmf-viewer> (Refs #122) 2026-10-28 22:06:44 +01:00
Adriano 2f0a5aaf1e Deviazione NaN nelle esportazioni CSV per le quote non misurate
Refs #124

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:06:43 +01:00
Adriano e96fbb73c2 Unisce lavoro/daitarn-tmf-v-wizard: visore standard <tmf-viewer> (Refs #122) 2026-10-28 22:06:43 +01:00
Adriano f3bacc8238 Unisce lavoro/daitarn-tmf-v-operatore: visore standard <tmf-viewer> (Refs #122) 2026-10-28 22:06:43 +01:00
Adriano e743363a62 Unisce lavoro/daitarn-tmf-visore: visore standard <tmf-viewer> (Refs #122) 2026-10-28 22:06:43 +01:00
Adriano d48a8a1076 <tmf-viewer>: clamp fit*8, viewer-error, etichette autonome, pagina di prova
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:05:54 +01:00
Adriano 8ef61a1285 Registra spento sul frame gia' registrato, nuovo grab dopo il reset
Refs #123

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:04:20 +01:00
Adriano 84fc1a08f7 Pagina camera 8765: i quattro visori diventano tmf-viewer, JS serviti su /visore/
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:03:58 +01:00
Adriano c904800e94 Etichetta NaN sull'immagine per le quote non misurate
Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:02:26 +01:00
Adriano 20c0a47c00 <tmf-viewer>: visualizzatore immagine standard, tmfSaveBlob e tmfSvgToPngBlob
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:59:01 +01:00
Adriano 269bbc03ca Wizard DXF: anteprima immagine, anteprima DXF e Prova usano <tmf-viewer>
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:58:27 +01:00
Adriano 815d49d699 Pannello visione operatore: zoom, overlay e Salva passano a <tmf-viewer>
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:58:18 +01:00
Adriano 43ef22079d Unione lavoro/daitarn-tmf-nan (Refs #118)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:41:43 +01:00
Adriano b01bba536a Test check: la quota non prodotta entra come NaN/fail
Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:41:22 +01:00
Adriano 17344aae13 Monitor: quota con soli NaN resta in elenco con media null e fuori tolleranza
Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:30:11 +01:00
Adriano fd3384f08c Monitor e quota registrata mostrano NaN invece di andare in errore (Refs #118)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:28:53 +01:00
Adriano 08aefa9888 Salva apre il «Salva con nome» prima di scaricare; riepilogo senza deviazione 0 per quota NaN (Refs #119 #118)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:23:55 +01:00
Adriano 06f308be6f Visione: quota non prodotta salvata come NaN (value NULL, fail) invece del 422; migrazione 022
Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:21:54 +01:00
Adriano 93afd6187c Unione lavoro/daitarn-tmf-wizard (Refs #118 #119 #120 #121)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:21:33 +01:00
Adriano 8217f46d47 Unione lavoro/daitarn-tmf-operatore (Refs #118 #119 #120 #121)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:21:33 +01:00
Adriano d4802fd1a3 Unione lavoro/daitarn-tmf-svg (Refs #118 #119 #120 #121)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:21:33 +01:00
Adriano ea64f3c524 Wizard DXF e riepilogo: overlay SVG, Salva immagine, NaN per quota non misurata
Refs #118 #119 #121

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:20:26 +01:00
Adriano 58baed19e2 Pannello visione operatore: barra senza input file con camera, Salva, Registra conta le quote, svg al posto del canvas, NaN
Refs #118 #119 #120 #121

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:20:21 +01:00
Adriano 25b0741003 Test del helper di salvataggio immagine e fix pulizia backslash nel nome
Refs #119

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:18:20 +01:00
Adriano df67a32a82 Test dell'overlay SVG e delle etichette senza valore
Refs #121 #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:17:29 +01:00
Adriano 5723f055c4 Overlay SVG e helper salva immagine per il pannello visione
Refs #121 #119 #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:16:11 +01:00
Adriano 4485803122 Unione vision_overlay.js nel pannello visione (Refs #116)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 17:01:28 +01:00
Adriano acd94a9d33 Pannello visione: integrazione overlay ed etichette quota (rows, drawOverlay, overlayLabels)
Refs #116

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 17:00:48 +01:00
Adriano 88b3f202b1 Pannello visione: nominale e tolleranze nella tabella quote
Refs #117

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:57:27 +01:00
Adriano 4a11d2595f Pannello visione: zoom, Adatta e trascinamento sull'immagine
Refs #115

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:54:13 +01:00
Adriano b46ccba1ee Pannello visione: modulo vision_overlay.js (forme overlay, ancore, etichette) con test node e fixture task 14
Refs #116

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:52:50 +01:00
Adriano 7a1df0bca4 Pannello visione: la tabella quote mostra description invece di #id
Refs #114

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 16:33:09 +01:00
Adriano 198c6c63c4 Wizard DXF: Adatta misura l'area senza barre di scorrimento (offset meno bordi). Refs #113
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 12:44:26 +01:00
Adriano 04b81ad2f2 Wizard DXF: correzioni revisione zoom anteprime (style statico, x-show, altezza utile). Refs #113
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:39:06 +01:00
Adriano 45c9e6c3f3 Wizard DXF: zoom, Adatta e pan sulle anteprime immagine e DXF. Refs #113
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:35:10 +01:00
Adriano ca6cd0cab1 Etichette uscite visione: count/n_points/area non ereditano la quantità del tool, ratio in %. Refs #112
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:17:13 +01:00
Adriano 34c251aa90 Test blocco: ciclo con latoAnteprima() al posto di 1280. Refs #111
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:59:25 +01:00
Adriano c70d29d6d7 Acquisizione: visore zoom, adatta() rinominata adattaVisore (collideva con quella dei parametri camera)
Aggiunto test che vieta funzioni dichiarate due volte nello script della pagina.

Refs #111

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:55:09 +01:00
Adriano 96aca975d0 Merge branch 'lavoro/daitarn-tmf-flip' into lavoro/daitarn-tmf-zoom 2026-10-28 10:53:00 +01:00
Adriano b3c56ef5b5 Acquisizione: zoom +/-, Adatta e pan sui tre riquadri immagine, lato anteprima che segue lo zoom
Barra in sovrimpressione fuori dal palco che scorre, Ctrl+rotella, pan col puntatore.

Refs #111

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:52:30 +01:00
Adriano 9111eb2741 Orientamento: se il salvataggio fallisce si ripristina lo stato di prima in memoria. Refs #110
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:49:46 +01:00
Adriano b46a70347f Merge branch 'lavoro/daitarn-tmf-flip-pagina' into lavoro/daitarn-tmf-flip-unione 2026-10-28 10:43:39 +01:00
Adriano 155abfd15c Acquisizione: caselle di specchiatura orizzontale e verticale per sorgente
Refs #110

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:39:49 +01:00
Adriano 2c2d601495 Specchiatura orizzontale/verticale per sorgente nel servizio camera, salvata su disco. Refs #110
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:38:49 +01:00
Adriano b2e3867370 Test scalda camere. Refs #108
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:16:40 +01:00
Adriano cab419bdc7 Avvio: scalda le camere configurate con un grab a vuoto. Refs #108
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:16:31 +01:00
Adriano 249999fa1e Test pagina e JS per conversione BMP->PNG. Refs #107
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:10:21 +01:00
Adriano a3e032d000 Disegno Tecnico: BMP convertiti in PNG nel browser, errore upload nel riquadro. Refs #107
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:07:52 +01:00
Adriano 57376556d2 camera: commento admin preciso, test timeout grab maker (Refs #106)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 17:57:43 +01:00
Adriano 7cb877b11b Timeout grab camera 60 s e sorgenti admin 15 s (prima apertura GenTL lenta). Refs #106
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 17:54:56 +01:00
Adriano 08616aa1fa maker: pan dell'immagine di prova non si interrompe col mouse (Refs #104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:46:57 +01:00
Adriano 02f1481fe5 maker: zoom in sovrimpressione, pan col puntatore e viewport che segue l'immagine (Refs #104)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:42:42 +01:00
Adriano 34e1b042f5 maker: risultati della Prova a destra dell'immagine da 640px (Refs #104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:36:44 +01:00
Adriano bdef1f1cd8 maker: anteprima DXF sempre a destra, avviso immagine tolto dopo il salvataggio (Refs #104 #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:35:11 +01:00
Adriano 39b9ad77eb vision: condizione della quota su due righe leggibili (Refs #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:31:16 +01:00
Adriano 12632323e8 merge #105 105b: wizard Modifica ricetta da DXF (Refs #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:31:10 +01:00
Adriano 839a61e479 merge #105 105a: ricetta DXF riapribile, backend (Refs #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:31:10 +01:00
Adriano 9b8e729927 maker: Modifica ricetta da DXF, il wizard si riapre compilato (Refs #105)
Due proxy Flask (/source e /files, byte) e wizard che all'init legge la ricetta
esistente: etichetta del bottone dinamica, apertura con banda, soglia, polarita',
mm/px, DXF e immagine di riferimento; risposte tardive non sovrascrivono le scelte manuali.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:27:20 +01:00
Adriano 6080e6aebc vision: ricetta DXF riapribile, immagine salvata, rotte source e files, soglia segue la quota (Refs #105)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:25:37 +01:00
Adriano d5db5f3014 merge #102 #104: anteprima immagine, disposizione del wizard DXF, prova su due colonne con zoom (Refs #102 #104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:21:43 +01:00
Adriano 1a19bb6679 maker: wizard DXF con anteprime sotto le scelte, mm/px solo da file, risultato su due colonne con zoom e Fit (Refs #104)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:21:19 +01:00
Adriano 20f33234a8 maker: anteprima immagine nel wizard Crea ricetta da DXF (Refs #102)
Anteprima dell'immagine scelta/acquisita accanto a quella del DXF, con
pixel e campo in mm (scala a mano o calibrazione stazione); fallback se
il formato non e' mostrabile. Test JS e di pagina, voci it/en.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:14:10 +01:00
Adriano 711abc96df merge #103: DXF a entità aperte unito in contorni chiusi (Refs #103)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:11:53 +01:00
Adriano 77419f9ebd vision: il DXF di confronto unisce le entita' aperte in contorni chiusi (Refs #103)
chiudi_contorni unisce LINE/ARC aperti in LWPOLYLINE chiuse prima di
costruire la ricetta e per l'anteprima: con banda larga cut_open_ends
non lasciava piu' punti in banda.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:11:18 +01:00
Adriano 4791c34205 merge #101 101b: anteprima DXF disegnata con path (Refs #101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:06:54 +01:00
Adriano e582df7962 maker: anteprima DXF disegnata con path e viewBox camel (Refs #101)
Un solo <path :d="dxfPreviewD()"> al posto di template x-for + polyline
(x-for non funziona nel namespace SVG) e x-bind:view-box.camel al posto
di :viewBox (il parser HTML abbassa il nome dell'attributo).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:02:04 +01:00
Adriano a7b91b5508 merge #101 101b: anteprima DXF nel wizard (Refs #101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:59:25 +01:00
Adriano addf5a2598 merge #101 101a: anteprima DXF nel wizard (Refs #101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:59:14 +01:00
Adriano bdb1e8c8b9 maker: anteprima del DXF nel wizard «Crea ricetta da DXF» (Refs #101)
Proxy Flask /maker/api/vision/dxf-preview (piano B), stato e funzioni
dxfPreview* nel wizard, riquadro SVG con L x H sotto il campo .dxf,
traduzioni e test (proxy, JS con node, pagina).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:56:45 +01:00
Adriano ebaafbe6e9 vision: anteprima DXF nel worker e nel backend (Refs #101)
Worker: POST /dxf/preview legge il DXF con read_xld_dxf (lo stesso lettore di
CompareDxf) e ritorna contorni in mm con y verso il basso, bbox, dimensioni,
entita e punti (tetto 20000, decimazione uniforme). Backend: POST
/api/vision/dxf-preview (Maker) che inoltra al worker; 422 inoltrato.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:55:43 +01:00
Adriano 351153e78a merge #100: statistiche con «Tutti i punti» (Refs #100)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:52:17 +01:00
Adriano 842ceca007 statistiche: con Tutti i punti tabella capability per punto, carta e istogramma solo con punto scelto, il select ricarica da solo (Refs #100)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:51:38 +01:00
Adriano b78f2422d8 merge #99: BMP nelle rotte visione (Refs #99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:47:21 +01:00
Adriano 444dc0a6d1 backend: immagini BMP ammesse nelle rotte visione (Refs #99)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:46:58 +01:00
Adriano 27d7c1e832 maker: mm/px a mano nel wizard DXF per immagini da file (Refs #98)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:37:40 +01:00
Adriano 4b1b23cfb6 Merge branch 'lavoro/daitarn-tmf-97c' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:04 +02:00
Adriano 4b57efc73c Merge branch 'lavoro/daitarn-tmf-97b' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:03 +02:00
Adriano 7c03f53000 Merge branch 'lavoro/daitarn-tmf-97a' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:03 +02:00
Adriano 67337fa232 operatore: immagine gia' analizzata all'ingresso, analisi impulsiva o in linea, Registra (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:30:31 +02:00
Adriano 661b782bad backend: test camera e modo di analisi nel task, /vision/check (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:26:26 +02:00
Adriano 25f8b4d609 maker: camera e modo di analisi nel task visione (Refs #97)
Rotte /maker/api/camera/servizi e /maker/api/camera/sorgenti (url ammesso solo
se e' un servizio delle stazioni), sezione «Acquisizione» nell'editor task
(servizio, sorgente, analisi impulsiva/in linea) salvata come acquisition_json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 18:26:00 +02:00
Adriano b98d1f15f4 operatore: immagine del task visione in fit, pagina senza scorrimento (Refs #96)
Pannello visione overflow-hidden/min-h-0, immagine e overlay object-contain
sovrapposti nel riquadro flex-1, colonna esito con scorrimento proprio.
Test sulle classi del template.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:20:15 +02:00
Adriano 8f9731839a backend: camera e modo di analisi nel task, /vision/check senza salvataggio (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:20:14 +02:00
Adriano 552f96bb47 acquisitore: calibrazione manuale con mm/px impostato a mano (Refs #95)
POST /camera/calibrazioni/manuale salva una voce senza mappa (metodo manuale);
anteprima/calibrata senza mappa risponde il frame grezzo (X-Raddrizzato: no);
scheda «Calibrazione manuale» nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:36:45 +02:00
Adriano 71daab184f acquisitore: il frame bloccato e' quello mostrato (Refs #94)
Con «Blocca immagine» la pagina aspetta l'anteprima in volo (max 3 s) e sospende
ciclo e «Scatta» prima di POST /camera/blocca, cosi' la copia del servizio e' l'ultimo
frame visibile nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:31:38 +02:00
Adriano 9beaf2ed67 acquisitore: immagine viva in Calibrazione e «Blocca immagine» per il calcolo (Refs #94)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:25:28 +02:00
Adriano 800ec41254 acquisitore: «Attiva calibrazione» con scelta e CONFERMA sotto l'immagine calibrata (Refs #93)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:03:16 +02:00
Adriano 9c45a51770 acquisitore: elenco sorgenti in cache, calibrata chiesta solo se serve (Refs #92)
Servizio: la ricerca camere di /camera/sources resta 60 s in cache (lock,
riscaldamento all'avvio, ?aggiorna=true la forza); le immagini caricate si
leggono sempre fresche. Pagina: l'immagine caricata entra nella select senza
rileggere l'elenco, pulsante Aggiorna, nessuna richiesta calibrata se la
sorgente non ha calibrazione attiva o dopo un 409.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:41:18 +02:00
Adriano 4a48af2ae3 merge #92 pagina: sorgente in cima, grezza e calibrata affiancate 2026-10-24 16:28:00 +02:00
Adriano b879563738 merge #92 servizio: immagini come sorgenti, anteprima calibrata 2026-10-24 16:28:00 +02:00
Adriano f504c86491 Acquisizione: immagine da file come sorgente, riquadri grezza e calibrata affiancati (Refs #92)
Pagina: Sorgente in cima alla tab Acquisizione con carica/rimuovi immagine,
riquadro Calibrata con casella, calibrazione sempre dal frame grezzo della
sorgente, ciclo anteprima sospeso nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:27:20 +02:00
Adriano 632fd51050 acquisitore: immagini caricate come sorgenti e anteprima calibrata (Refs #92)
POST/DELETE /camera/immagini (cartella <config>/immagini), elenco sorgenti con le immagini e senza camera:file, apertura per nome di file con loop, frame grezzo tenuto dall'anteprima e POST /camera/anteprima/calibrata che raddrizza senza leggere un frame nuovo. 22 test nuovi.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:26:30 +02:00
Adriano 8eb80e20a8 Acquisizione: elenco calibrazioni a schede, parametri in lettura (Refs #91)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:14:37 +02:00
Adriano b2f82fe779 merge #91 pagina: tab Acquisizione / Calibrazione 2026-10-24 16:10:25 +02:00
Adriano 3c5c5f9bc5 merge #91 servizio: calibrazioni con nome, archivio unico 2026-10-24 16:10:25 +02:00
Adriano a5e8472e88 acquisitore: rotte per calibrazioni con nome, caricamento con controllo risoluzione (Refs #91)
/camera/calibrate non salva piu' (calcolo_id in memoria, ultimi 8); nuove rotte
GET/POST/DELETE /camera/calibrazioni e PUT /camera/calibrazione-attiva (409 se la
risoluzione non combacia col frame della sorgente); DELETE /camera/calibration ora
scollega. Test: rotte nuove e test esistenti aggiornati (save=true -> salva con nome).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 16:10:10 +02:00
Adriano d6cee498ad Acquisizione: pagina a due tab, calibrazioni salvate con nome e carica per sorgente (Refs #91)
Tab Acquisizione | Calibrazione con anteprima sempre visibile; il calcolo non salva piu',
Salva usa calcolo_id e un nome (conferma di sovrascrittura al secondo clic);
elenco unico con Carica / Scollega / Elimina e segno di risoluzione diversa.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:08:02 +02:00
Adriano e7d5489438 acquisitore: archivio unico di calibrazioni con nome, migrazione dal formato per sorgente (Refs #91)
Archivio: libreria calibrazioni/<nome>-<sha>.json (+npz), attive.json per sorgente,
salva_nome/leggi_nome/elimina_nome/attiva/scollega/attiva_di/elenco; leggi e
leggi_mappa risolvono la calibrazione attiva. Le rotte seguono nel commit successivo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 16:06:05 +02:00
Adriano 828471d27a Acquisizione: anteprima a pausa minima 100 ms (Refs #90)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 15:49:17 +02:00
Adriano cf64a247be acquisitore: frame sempre piu' recente (Refs #89)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 15:44:38 +02:00
Adriano 813b66d0c5 Acquisizione: parametri camera con un decimale nelle caselle (#87)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 15:28:59 +02:00
Adriano 8c6d6c1630 sviluppo.ps1: -EscludiCamere; bump visionsuite VS_CAMERA_ESCLUDI (TieVision-Core#339)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 15:25:13 +02:00
Adriano 1afaaa0bae packaging: sviluppo.ps1, TMF da sorgente senza build Nuitka (#87 #88)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 15:09:33 +02:00
Adriano cb66371a5b Stazioni: x-cloak rimesso sulle modali, tolto per sbaglio (#88)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:58:23 +02:00
Adriano 390d6210d0 Stazioni: sorgenti camera lette dall'acquisitore, link Acquisizione (#88)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:51:22 +02:00
Adriano 2760623b53 Merge remote-tracking branch 'origin/lavoro/daitarn-tmf-acq-pagina' into lavoro/daitarn-tmf-dxfcal 2026-10-24 14:41:55 +02:00
Adriano 13eeec7119 Acquisizione: pagina, attributo hidden rispettato, indicatori su una riga, mirino a 2 px (#87)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:41:25 +02:00
Adriano 0629f1b5ec Acquisizione: pagina nuova con anteprima, parametri camera e stile TMF (#87)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:38:39 +02:00
Adriano 9eb5942459 Acquisitore: anteprima, parametri camera e settaggi di default (#87, piano A)
Rotte nuove nel servizio camera: POST /camera/anteprima (JPEG ridotto con
nitidezza, saturati e media sul frame pieno), GET/PUT /camera/parametri
(esposizione e guadagno con i relativi auto), POST /camera/parametri/salva.
I settaggi salvati vengono ricaricati alla prima apertura della camera.
GET /acquisizione serve la stessa pagina di /calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:36:18 +02:00
Adriano 11c7d2e98b DXF compare: ancore ICP sul solo pezzo staccato dal bordo, errore ICP con motivo (#86)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 13:54:25 +02:00
Adriano 88bba213e2 bump vendor/visionsuite: guardia overlay fuori immagine (TieVision-Core#337)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 13:53:01 +02:00
Adriano 8ce7b489d6 Merge branch 'lavoro/daitarn-tmf-seedfiera' into lavoro/daitarn-tmf-dxfcal 2026-10-24 10:17:47 +02:00
Adriano bc90d327f1 seed fiera: password 123 per tutti, chiesta da Adriano (#85)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 10:17:45 +02:00
Adriano e46965cb51 Merge branch 'lavoro/daitarn-tmf-seedfiera' into lavoro/daitarn-tmf-dxfcal 2026-10-24 00:55:21 +02:00
Adriano c554fcfda7 seed fiera: commento sulle password corretto (#85)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 00:55:20 +02:00
Adriano 08e46e335b Merge branch 'lavoro/daitarn-tmf-calibdensa' into lavoro/daitarn-tmf-dxfcal 2026-10-24 00:49:52 +02:00
Adriano e38e1a2eaf Calibrazione densa: finestra sub-pixel ristretta al bordo, test su scacchiera continua (#84)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 00:00:36 +02:00
Adriano 7fa5bacdfe Acquisitore: calibrazione densa su C2/grab/pagina, test (#84)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-23 23:56:15 +02:00
Adriano 896a30b376 Calibrazione densa da una foto: griglia, mappa di distorsione, archivio .npz (#84)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-23 23:53:48 +02:00
Adriano 13dbc8f13b Merge branch 'lavoro/daitarn-tmf-dxfcal-frontend' into lavoro/daitarn-tmf-dxfcal 2026-10-23 23:52:00 +02:00
Adriano 8925968f34 Merge branch 'lavoro/daitarn-tmf-dxfcal-backend' into lavoro/daitarn-tmf-dxfcal 2026-10-23 23:52:00 +02:00
Adriano 2959b79fa2 Merge branch 'lavoro/daitarn-tmf-dxfcal-worker' into lavoro/daitarn-tmf-dxfcal 2026-10-23 23:52:00 +02:00
Adriano 990b5d0db8 Seed fiera: utenti, stazione, ricette e storico misure (#85)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-23 23:51:00 +02:00
Adriano 1bfcfaa5f2 Ricetta DXF da camera: pannello editor, stato calibrazione stazioni, proxy camera (#83)
Rotte maker: elenco stazioni con camera, grab per stazione, stato calibrazione
dall'acquisitore (C1), inoltro a B3. Pagina stazioni: stato e link «Calibra».
Test con api_client e requests finti; traduzioni IT/EN.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:51:11 +02:00
Adriano ad6ce5d11d Backend: ricetta DXF compare da camera e calibrazione letta dall'acquisitore (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:44:15 +02:00
Adriano 351739ec36 Acquisitore: calibrazione a scacchiera per sorgente, rotte e pagina /calibrazione (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:43:32 +02:00
Adriano 9f2d624b4f Worker: endpoint /recipes/dxf-compare con test (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:40:08 +02:00
Adriano 9fe82d7cef Costruttore ricetta DXF compare dal campo, con test (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:39:21 +02:00
Adriano ae2353f7f8 Pacchetto Windows: registra gli strumenti senza entry point e controlla il catalogo nel build (#82)
rilascio-windows / setup-windows (push) Successful in 32m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMYrdz6uBbuwbaQ9MqDmMc
2026-10-19 23:03:48 +02:00
Adriano d652891c1c Merge lavoro/daitarn-tmf-81 (TieMeasureFlow#81)
rilascio-windows / setup-windows (push) Successful in 34m11s
2026-10-17 14:25:56 +02:00
Adriano 0b22643d69 Merge lavoro/daitarn-tmf-80 (TieMeasureFlow#80) 2026-10-17 14:25:56 +02:00
Adriano 15e31e2b8a Merge lavoro/daitarn-tmf-79 (TieMeasureFlow#79) 2026-10-17 14:25:55 +02:00
Adriano f8f5f248ff setup: init-db e reset-db stampano head in un thread (TieMeasureFlow#80)
_stamp_head esegue env.py di alembic, che fa asyncio.run() e dentro il loop
di FastAPI dava RuntimeError (500). Ora gira con asyncio.to_thread. Nuovo
test_setup_stamp.py senza monkeypatch di _stamp_head, su file SQLite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:25:24 +02:00
Adriano f33415df6a Test frontend: niente piu' dipendenza dall'ordine sulla stazione (TieMeasureFlow#79)
test_measure.py non ricarica piu' config/blueprints.measure (monkeypatch.setattr,
annullato al teardown); TestTaskComplete e test_recipe_rules_ui.py fissano la
stazione esplicitamente con una fixture autouse.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:13:15 +02:00
Adriano 62b5e412ca static del frontend sempre LF (.gitattributes): impronte di VERSIONS.md e pacchetto Windows identici (TieMeasureFlow#81)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:10:39 +02:00
Adriano 0e92959022 Merge piano C: setup Windows di TieMeasureFlow (TieMeasureFlow#76)
rilascio-windows / setup-windows (push) Successful in 32m46s
2026-10-17 12:39:35 +02:00
Adriano f23e87e585 Merge piano B: report PDF su Windows offline (TieMeasureFlow#78) 2026-10-17 12:39:35 +02:00
Adriano 114e4a10ae Merge piano A: backend su SQLite (TieMeasureFlow#77) 2026-10-17 12:39:34 +02:00
Adriano 3aac8cc490 CLAUDE.md: trappole del pacchetto Windows (stazione, alembic, init-db) (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 11:28:12 +02:00
Adriano 8b3e897668 Workflow rilascio-windows: corregge il percorso di Node nel PATH (YAML valido) (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 11:25:22 +02:00
Adriano eea3e319fb Pacchetto Windows: STATION_CODE predefinito e chiavi extra da config.env, alembic.ini sourceless, fileConfig neutralizzato (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 10:55:48 +02:00
Adriano bfc7c391e8 build.ps1: niente metadati vs-* (Nuitka li rifiuta per i pacchetti del namespace visionsuite) (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:29:45 +02:00
Adriano 8d4c4020f6 Pacchetto Windows: tzdata nel gruppo windows, flag Nuitka dal LEGGIMI del piano PDF, trappole in CLAUDE.md (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:20:58 +02:00
Adriano 0a68a9995e Pacchetto Windows: LEGGIMI dei report PDF (DLL, variabili, flag Nuitka, licenze) (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:18:21 +02:00
Adriano 959e92552b Pacchetto Windows: test dell'avvio, workflow rilascio-windows, sezione README (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:17:18 +02:00
Adriano 8431aa7095 Report PDF su Windows: MathJax locale per Kaleido (niente CDN offline) e test con WeasyPrint/Edge veri (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:16:29 +02:00
Adriano 69cbf53060 Pacchetto Windows: build.ps1 (Nuitka standalone + migrazioni .pyc + Inno Setup), installer .iss, icona (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:14:01 +02:00
Adriano 23012fdfbb Pacchetto Windows: prendi-gtk.ps1 scarica le DLL Pango/GTK pinnate con sha256 (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:10:11 +02:00
Adriano d89a2e6c8d Pacchetto Windows: avvio.py (licenza, config.env, migrazioni, quattro server in thread), vs-camera nel gruppo windows (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:10:05 +02:00
Adriano 242add047b Backend su file SQLite locale: DB_URL, PRAGMA FK/WAL, UPLOAD_DIR assoluto (TieMeasureFlow#77)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:07:45 +02:00
Adriano 09c4029978 Gruppo uv windows per il pacchetto Windows: aiosqlite, waitress, vs-license, nuitka (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:07:22 +02:00
Adriano cd40b81f1e Merge lavoro/daitarn-tmf-core2e7: vendor TieVision-Core 2e73691 (Fixes #75) 2026-10-17 00:39:43 +02:00
Adriano 6e60e0b1a9 vendor TieVision-Core 4c178cf -> 2e73691: CompareDxf dxf_path su recipe_dir, catalogo EdgesBetweenSegments (#75)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8FDdHxKu17fSf9T6aZETK
2026-10-17 00:39:36 +02:00
Adriano 6bc6c0c0d5 Submodule vendor/visionsuite: da visionsuite a TieVision-Core, pin 4c178cf (#74)
Il repo e' stato rinominato il 15/10/2026 (TieVision-Core#329): .gitmodules punta
al nuovo URL, il pin passa da cdc7a14 a 4c178cf, CLAUDE.md e il commento di
scripts/camera_service.py seguono la divisione (vs-gui e vs-assistant in
TieVision-Studio e TieVision-Assistant). Test visione: 29 passed prima e dopo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 17:49:13 +02:00
Adriano e5989c9d23 Merge lavoro/jeeg-tmf72: camera di stazione per la demo in fiera (#72)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 10:02:50 +02:00
Adriano 0af6692786 Servizio camera minimo di stazione con test e README (#72)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 10:01:46 +02:00
Adriano 36074ab2f7 Merge frontend camera di stazione (#72) 2026-10-15 09:53:41 +02:00
Adriano fd1bbce8aa Merge backend camera di stazione (#72) 2026-10-15 09:53:41 +02:00
Adriano 1453b31cee by-code: auth da utente loggato qualunque, come /recipes (#72)
Lo chiama il proxy Flask con la chiave dell'operatore MeasurementTec, quindi
get_current_user al posto di require_admin_user. Test: MeasurementTec 200,
senza chiave 401.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:53:33 +02:00
Adriano 192d4b590c Camera di stazione: acquisizione dal frontend e campi admin (#72)
Route POST /measure/api/vision/grab (proxy verso il servizio camera),
get_station_by_code, pulsante «Acquisisci dalla camera», grab() in
vision_execute.js, campi camera_url e camera_source nell'admin stazioni,
traduzioni it/en, extra_hosts host.docker.internal nel compose di sviluppo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:51:07 +02:00
Adriano b9f43a0ede Stazioni: camera_url e camera_source, GET /api/stations/by-code/{code} (#72)
Migrazione 020 con le due colonne su stations, campi in modello, schemi e
servizio (validatore http(s) come vision_worker_url), nuova route by-code
dichiarata prima di /{station_id}. Test di migrazione su e giu e di API.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:50:14 +02:00
Adriano 5bf374aadd Merge lavoro/jeeg-tmf71: 015_api_key_hash idempotente (Fixes #71)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 09:28:40 +02:00
Adriano 74fe5665b5 015_api_key_hash idempotente: DB da create_all e 015 a metà su MySQL (#71)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:25:07 +02:00
Adriano be904bac07 Merge lavoro/daitarn-tmf73: vendor visionsuite cdc7a14 (Fixes #73)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcYsFQDaTPnDVjrQkWj8R
2026-10-13 09:26:04 +02:00
Adriano 011c08c9d8 vendor visionsuite adf7a26 -> cdc7a14: revisione GUI e assistente (visionsuite#305-#316) (#73)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcYsFQDaTPnDVjrQkWj8R
2026-10-13 09:26:00 +02:00
Adriano 82ad88538d Merge lavoro/daitarn-70: versione e commit nel piè di pagina (Fixes #70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019u7uKckaZ7DBe4oGbiwuHa
2026-10-12 14:25:04 +02:00
Adriano bafc776ef7 Versione e commit nel piè di pagina (#70)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:22:02 +02:00
Adriano 8fcb4796d5 Merge lavoro/daitarn-58: esiti, note, campi e allegati dei task nella statistica per sessione (Fixes #58)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:22:39 +00:00
Adriano 8d7fa9a12a Statistica: pannello esiti, note, campi e allegati dei task per sessione, proxy e traduzioni (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:08:18 +00:00
Adriano c911449b7c Statistica: endpoint esiti, note, campi e allegati dei task di una sessione (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:07:06 +00:00
Adriano ac8604eff5 Merge lavoro/daitarn-45: worker di visione configurabile per stazione (Fixes #45)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:03:57 +00:00
Adriano 631ba4ee05 Stazioni: placeholder senza URL letterale (test_offline), test form indipendente dalla lingua (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:49:46 +00:00
Adriano 40eed5c2af Admin stazioni: campo Worker visione (URL), traduzioni e test (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:39:22 +00:00
Adriano 275bbd1a5f Test worker di visione per stazione, API stazioni, migrazione 019 (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:38:03 +00:00
Adriano 95d643d6d8 Worker di visione per stazione: modello, API, migrazione 019, execute_task (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:37:08 +00:00
Adriano 9306fbf653 Merge lavoro/daitarn-tmf69: vendor visionsuite adf7a26 (Fixes #69)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 16:44:06 +02:00
Adriano 1a5e98f072 vendor visionsuite adf7a26: ricette robuste ai PermissionError di Windows (#69)
Da dbd1a55 a adf7a26 (visionsuite#279): vs-task server/recipes.py ritenta lettura e scrittura su PermissionError. uv.lock invariato (uv lock --check ok). Suite visione 3.13: 34 passati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 16:44:03 +02:00
Adriano fdfcf05951 Merge lavoro/daitarn-tmf68: vendor visionsuite riallineato a main dbd1a55 (Fixes #68)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 14:07:03 +02:00
Adriano 26884ebf4f vendor visionsuite dbd1a55: riallineato a main (#68)
Da 1e45b33 a dbd1a55 (87 commit): vs-dxf, vs-pm2d, vs-tools, vs-task aggiornati; uv.lock invariato (uv lock --check ok).
Suite visione 3.13: prima 29 passati, dopo 34 passati (con test_vision_boundary).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 14:06:50 +02:00
Adriano 8b3d3a8424 vendor visionsuite 1e45b33: Ricette a griglia con azioni in riga (visionsuite#262) (#67)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNVrf2wZAmMAkgmwEuBB36
2026-10-07 22:08:56 +02:00
Adriano 153fafd1b4 vendor/visionsuite a main 8d5ac89: quote, Misure, tolleranza, tab, SORGENTE (#67)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNVrf2wZAmMAkgmwEuBB36
2026-10-07 21:35:31 +02:00
Adriano cf8fab4c08 vendor/visionsuite a main 9e25714 (#67)
GUI vs-gui riprogettata, choices nel catalogo, enum feature_layout in inglese con alias.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNVrf2wZAmMAkgmwEuBB36
2026-10-07 18:41:16 +02:00
Adriano b7cb061fee Suite: test PIN, cornice e worker PDF allineati all'integrazione (#65)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:46:27 +02:00
Adriano 6a8ad4dae4 Allegati delle note verso /api/task-results/attachments (#55)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNVrf2wZAmMAkgmwEuBB36
2026-10-07 13:12:47 +02:00
Adriano d750cf9e0e Merge lavoro/daitarn-trafilo-p5 2026-10-07 13:11:58 +02:00
Adriano 2564498915 Test copy-on-write dei task: rilettura della collezione in sessione condivisa (#53)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:11:30 +02:00
Adriano 21f8bae5ac Task results: upload allegati per MeasurementTec, percorsi in task_results/, test e migrazione 018 (#55 #53 #54)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:50:42 +02:00
Adriano c1f8a5e2ff Merge lavoro/daitarn-trafilo-i18n 2026-10-07 12:49:12 +02:00
Adriano dee27ac8e1 Traduzioni it/en stringhe Tràfilo (#47–#64)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:48:53 +02:00
Adriano 736b853e22 Fermo linea e Fine produzione anche sui task check del ciclo (#53)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNVrf2wZAmMAkgmwEuBB36
2026-10-07 12:47:00 +02:00
Adriano 627ae15591 Merge lavoro/daitarn-trafilo-esegui-esiti 2026-10-07 12:43:57 +02:00
Adriano c6d4260ede Esecuzione: esiti OK/NOK, campi dei task note, task di chiusura prima del capoturno (#53 #54 #55)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:43:04 +02:00
Adriano df1727be73 Task: in_loop, at_close, fields, tipo check, task_results, close_task_ids, rate limit login-pin (#53 #54 #55 #59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:42:13 +02:00
Adriano 459d1c71ea Merge lavoro/daitarn-trafilo-esegui-pin 2026-10-07 12:39:59 +02:00
Adriano 05e3faa9d4 Merge lavoro/daitarn-trafilo-utenti-pin 2026-10-07 12:39:58 +02:00
Adriano dc94b2e778 Capoturno: autorizzazione con PIN (nome + tastierino) o password nella modale (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:39:07 +02:00
Adriano 3c55550d93 Utenti: PIN impostabile/togliibile dall'admin, link Monitor nel menu (#51 #59 #57)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:38:58 +02:00
Adriano e07c4d4eee Merge lavoro/daitarn-trafilo-editor-task 2026-10-07 12:38:57 +02:00
Adriano 0b45f1c35a Merge lavoro/daitarn-trafilo-editor-quote 2026-10-07 12:38:56 +02:00
Adriano 743f246cbf Merge lavoro/daitarn-trafilo-pin 2026-10-07 12:38:56 +02:00
Adriano f541f4a810 Editor task: tipo check, ciclo di misura, nota a fine produzione con campi (#53 #54 #55)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:38:05 +02:00
Adriano dffb11f9f2 task editor: liste Tipo/Unita e tolleranze generali dalle Impostazioni (#47, #48)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:37:14 +02:00
Adriano c8dd5eecfd Merge lavoro/daitarn-trafilo-ritaglio 2026-10-07 12:33:02 +02:00
Adriano f7d6d008c7 Merge lavoro/daitarn-trafilo-duplica 2026-10-07 12:33:02 +02:00
Adriano 837f3d1069 Merge lavoro/daitarn-trafilo-statsessione 2026-10-07 12:33:02 +02:00
Adriano f5a2affa0f Merge lavoro/daitarn-trafilo-monitor 2026-10-07 12:33:02 +02:00
Adriano 7ef28b7a9f Merge lavoro/daitarn-trafilo-operatore 2026-10-07 12:33:02 +02:00
Adriano 06b87a15b3 Merge lavoro/daitarn-trafilo-login 2026-10-07 12:33:02 +02:00
Adriano 3b3ecceb2a Merge lavoro/daitarn-trafilo-settings 2026-10-07 12:33:02 +02:00
Adriano 3899d5ecd9 Merge lavoro/daitarn-46-motore: percorsi con lettera di unità (#46) 2026-10-07 12:32:47 +02:00
Adriano f986c172c2 Worker: recipe_dir con lettera di unita rifiutato su ogni OS (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:32:34 +02:00
Adriano e44ac3129f Statistiche sessioni: conteggio misure solo sui run della ricetta (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:31:53 +02:00
Adriano 4e52095423 Statistiche per sessione di produzione: filtro production_run_id, elenco sessioni, select Sessione (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:28:35 +02:00
Adriano 23bfc93f1c Impostazioni: durata loop, tolleranze, anagrafiche; tab Utenti con reset password (#47 #48 #49 #50 #51)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:28:31 +02:00
Adriano 07e23b957d PIN a 4 cifre: migrazione 017, login-pin, pin-users, PUT pin, capoturno via PIN (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:27:31 +02:00
Adriano c1c7ae8d0e Merge lavoro/daitarn-46-operatore (#46) 2026-10-07 12:27:16 +02:00
Adriano cf49695140 Merge lavoro/daitarn-46-maker (#46) 2026-10-07 12:27:16 +02:00
Adriano c9e01a223b Merge lavoro/daitarn-46-backend (#46) 2026-10-07 12:27:15 +02:00
Adriano 3690c35c47 Merge lavoro/daitarn-46-motore (#46) 2026-10-07 12:27:15 +02:00
Adriano fc1902a1bb Ricetta: il digest della cartella copre anche i file di meta (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:27:04 +02:00
Adriano 6648882ec1 Maker: duplica ricetta e durata loop predefinita (#52, #49)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:26:49 +02:00
Adriano b5aa19170e Maker: ritaglio di immagine e PDF prima dell'upload (#56)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:26:38 +02:00
Adriano 2d03577165 loadProductionRun mostra il detail del server; START ACTIVITY in inglese (#61 #62)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:26:33 +02:00
Adriano 27dc5e77af Schermate operatore: un solo Completato grande, Fermo linea solo con run aperto, AVVIA ATTIVITA, pannello lotto/seriale dopo la scelta (#60 #61 #62 #63 #64)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:25:56 +02:00
Adriano 8e3316957c Monitor stazioni: pagina /monitor e proxy JSON (#57)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:25:51 +02:00
Adriano 3c1d5f1323 Duplica ricetta: servizio, endpoint e test backend (#52)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:25:25 +02:00
Adriano db5d686938 Monitor stazioni: GET /api/production-runs/open (#57)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:25:16 +02:00
Adriano c764cf73c6 Login Operatore con nome + PIN: elenco nomi, tastierino, route login-pin (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:23:51 +02:00
Adriano 86290274e5 Test ricetta v2, execute dxf_compare, migrazione 016 (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:15:02 +02:00
Adriano ee0f65ed39 Endpoint ricetta VisionSuite v2, execute con overlay e immagine salvata (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:12:50 +02:00
Adriano 18d0db8a0c Migrazione 016 vision_recipe_dir, copy-on-write, dxf_compare tra i task di misura (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:08:29 +02:00
Adriano 3856ee335a operatore: acquisizione immagine, proxy execute e overlay per camera_measure/dxf_compare (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:55:42 +02:00
Adriano e640c4a36e Maker: ricetta visione e uscita per quota, tipi camera_measure/dxf_compare (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:55:31 +02:00
Adriano accf416cc9 Motore v2: run_graph con recipe_dir/calibrazione/overlay, /validate, test (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:54:27 +02:00
Adriano 84a4b5f34d Regola submodule VisionSuite su origin/main (bc0df16), uv.lock (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:51:06 +02:00
Adriano 9238f8926b visionsuite bc0df16, vs-tools e vs-dxf nelle dipendenze (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:50:35 +02:00
Adriano a829bf3eb0 frontend: date UTC convertite in ora italiana alla presentazione (#42)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:59:18 +02:00
Adriano 7ac291b8ac Backend: date dei filtri dei report e utenti setup in ora italiana/UTC esplicito (#42)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:59:07 +02:00
Adriano 486e1288bf Backend: UTC nel DB, +00:00 nelle API, ora italiana solo in presentazione (#42)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:41:22 +02:00
Adriano 0d004c43ff Login: cancella le key scadute dell'utente (#41)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:34:30 +02:00
Adriano 35f46926a9 API key come hash SHA-256, una per sessione, TTL rinnovabile (#41)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:34:30 +02:00
Adriano 1d93eded27 Ripristino delle quote già misurate nel ciclo in corso dopo un reload (#40)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:31:57 +02:00
Adriano 70e2072b79 File di riferimento rimossi con il task, immagini animate rifiutate, filtro production_run_id (#43)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:27:23 +02:00
Adriano 97c49f2b5b registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:37:50 +02:00
Adriano d483fa1b6a fix(docker): uv run senza sync nelle immagini server e client, cache uv dell'utente app (#39, #38)
Le immagini server e client non portano vendor/visionsuite: ogni uv run
rivalidava il progetto e falliva. UV_NO_SYNC=1 usa il venv di uv sync.
La cache uv creata da root in build va ceduta all'utente app.
Verificato con build e run delle due immagini.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:53:12 +02:00
Adriano 48c47e871e docs: conteggi test aggiornati nel README (#38.12)
I numeri erano fermi a 390 pass/1 fail/4 skip, con un fallimento
descritto come preesistente (test_offline.py::test_no_first_party_script_calls_out)
che oggi non c'e' piu'. Aggiornati ai risultati effettivi dei comandi
di verifica:
uv run --extra server --extra client --extra dev pytest -q -> 622 passed, 4 skipped
UV_PROJECT_ENVIRONMENT=... uv run --python 3.13 --extra vision-worker --extra dev pytest src/vision src/vision_worker -q -> 16 passed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:42:47 +02:00
Adriano cc686129c0 feat(measure): proxy JSON per il progresso di un task (#37.8, parziale)
GET /measure/api/task-progress/<task_id>: il browser non puo' chiamare
/api/measurements/task-progress del backend (niente X-API-Key). Login +
stesso controllo di stazione di task_execute() (_recipe_allowed_at_station),
poi inoltra a _task_progress() con lo stesso version_id/production_run_id
gia' usato da task_list.html.

Non tocca task_execute.html: /api/measurements/task-progress risponde
con conteggi aggregati per task (quotes/measured/state), non con le
quote-per-quota misurate (subtask_id, value, pass_fail, deviation) che
`this.measurements` richiede - ripristinarlo avrebbe richiesto inventare
valori o uno stato non offerto dal backend. MANCA: mostrato in dettaglio
nel rapporto finale.

Test Flask della route: riga per il task richiesto, default a "empty"
se assente, 403 fuori stazione, propagazione 404, login richiesto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:36:42 +02:00
Adriano b27d56d4f7 fix(maker): l'editor task si riallinea al copy-on-write invece di 409 (#4)
Dopo il copy-on-write del backend (modifica su versione con misure ->
nuovi id per task e quote), task_editor.html teneva in memoria gli id
vecchi: il salvataggio successivo riceveva 409 "Cannot modify a
non-current version". Ogni handler di salvataggio (create/update/delete
di task e quote, reorder, upload immagine) ora ricarica la pagina su
409, mostrando un avviso dopo il reload. updateTask/updateSubtask/
addSubtask ricaricano anche su successo se l'id restituito (recipe_id
task, task_id quota) non corrisponde a quello locale - il copy-on-write
avvenuto silenziosamente.

Stringa aggiunta ai due cataloghi .po. Test: proxy Flask che verifica
il passaggio del 409 (test_maker.py), sintassi JS dello script inline
di task_editor.html con node --check (test_template_js_syntax.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:33:01 +02:00
Adriano 96dd8d6ea3 fix(vision): reference image sopravvive al copy-on-write (#38.6)
_copy_tasks_to_version non copiava le righe VisionReferenceImage del
task sorgente: il task copiato perdeva le immagini di riferimento del
grafo di visione. Ora copia le righe sul task nuovo, stesso path e
stessi metadati (stesso file su disco, non duplicato).
Di conseguenza il file va cancellato solo se nessun'altra riga
VisionReferenceImage referenzia lo stesso path: nuova
vision_service.file_still_referenced(), usata dalla route DELETE
prima di remove_reference_file.
Test: copy-on-write elenca l'immagine sulla nuova versione; DELETE di
una delle due righe lascia il file; DELETE dell'ultima lo rimuove.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:27:29 +02:00
Adriano 2b00f83a2e test(tasks): copre recipe_id nel copy-on-write di create_task (#4)
Sospetto MissingGreenlet su TaskResponse.recipe_id (via RecipeTask.version)
quando create_task passa per il copy-on-write - il task appena creato non
ha .version precaricato. Test aggiunto: verde, non riproducibile. La
lazy load risolve via identity map (new_version è già nella sessione,
FK semplice → use_get), senza toccare il DB. Test tenuto come guardia
di regressione.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:25:17 +02:00
Adriano e10585a071 fix(docker): home dir e cache uv per l'utente non privilegiato (#38.1)
useradd senza -m lascia l'utente app senza $HOME: uv run nel CMD non
trova dove scrivere la sua cache. Aggiunto -m a useradd in Dockerfile,
Dockerfile.frontend e Dockerfile.vision, e UV_CACHE_DIR=/tmp/uv-cache
esplicito (indipendente da $HOME). docs/DEPLOYMENT.md: nuova sezione
sull'aggiornamento di un'installazione esistente, con il chown del
volume upload_data ereditato da versioni precedenti.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:23:31 +02:00
Adriano 8b43e7a601 fix(deploy): X-Frame-Options coerente fra Traefik e nginx (#38.11)
traefik.http.middlewares.tmflow-headers.headers.frameDeny=true dava
DENY in produzione, mentre nginx.conf (dev) usa SAMEORIGIN. Sostituito
con customFrameOptionsValue=SAMEORIGIN per allineare le due vie di deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:22:50 +02:00
Adriano 2de226f1fd fix(deploy): APP_ENV esplicito nei compose (#33)
docker-compose.yml (produzione) non impostava APP_ENV: la guardia
_refuse_default_secrets_outside_dev in src/backend/config.py non
scattava mai perché il default è "dev". Aggiunto APP_ENV: production
al servizio server; docker-compose.dev.yml imposta APP_ENV: dev in
modo esplicito. Tabella env in docs/DEPLOYMENT.md aggiornata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:22:39 +02:00
Adriano ddb764d639 i18n: voci .po per le 3 nuove stringhe del lotto B2, test EN reso significativo
Aggiunte a mano in coda a it/en messages.po (nessuna rigenerazione):
"Sessione scaduta: effettua nuovamente l'accesso." (#12/#25),
"Ricetta non assegnata a questa stazione" (#24), "Codice non valido" (#37.4).

test_admin_stations_inline_js_is_valid_in_english (#37.5): i .mo sono
gitignored e compilati solo in Docker, non in questo run - impostare
session["language"]="en" da solo non aveva alcun effetto e il test passava
senza esercitare il bug EN reale. Ora patcha jinja_env.globals["_"] con la
traduzione nota per quella stringa, verificato che fallisca davvero sul
codice pre-fix (ripristinato subito dopo) prima di lasciarlo verde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:16:43 +02:00
Adriano c37533110b fix(annotation-editor): ricalcola marker/frecce/aree al resize del canvas (#28)
_fitCanvasToImage() prima scalava solo canvas e sfondo: gli oggetti già
piazzati restavano alle vecchie coordinate pixel, e un salvataggio subito
dopo scriveva il nuovo imageScale accanto a posizioni mai spostate -
markers/frecce/aree finivano fuori posto anche nel viewer dell'operatore.

Ora, quando la scala cambia (f = newScale/oldScale, conservata in
this._currentScale), ogni oggetto viene ricalcolato coerentemente con
getAnnotationsJson/loadAnnotationsJson (il riferimento):
- left/top sempre *= f
- area (fabric.Rect): width/height *= f, non scaleX/scaleY - è quello che
  getAnnotationsJson legge e loadAnnotationsJson ricostruisce
- text (IText): fontSize *= f, stesso motivo
- marker/arrow (Group): scaleX/scaleY *= f, il loro width/height non viene
  mai riletto al caricamento
- arrow: arrowX1/Y1/X2/Y2 *= f, sono nello stesso spazio di left/top
  (li aggiorna anche object:modified con un delta) e finiscono nel JSON

Aggiunta guardia beforeunload quando isDirty (annotazioni non salvate).

Test in tests/js/test_annotation_editor_resize.js (node, senza Fabric.js
reale: richiede jsdom non installato e il progetto non ha npm deps -
canvas stand-in minimale che copre solo la superficie usata da
_fitCanvasToImage). annotation-viewer.js non toccato.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:11:39 +02:00
Adriano 12d5b7e154 feat(config): SESSION_COOKIE_SECURE leggibile dall'ambiente (#38.4)
config.py: default invariato (sicuro salvo FLASK_DEBUG=1); nuova env
SESSION_COOKIE_SECURE=false la spegne esplicitamente, per l'installazione
on-premise dove nginx serve HTTP in LAN - un cookie Secure su origine non
HTTPS viene scartato dal browser in silenzio e il login non regge mai.
.env.example documenta la variabile (commentata, comportamento invariato
di default). docker-compose e default dev restano al lotto C2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:06:59 +02:00
Adriano 2b59c3ec49 fix(recipe): measurement_interval_minutes e image_path si possono di nuovo azzerare (#37.3)
recipe_editor.html saveRecipe(): i due campi vengono sempre inviati nel
payload (valore o null esplicito), non più omessi quando svuotati.
recipe_service.py _header_fields(): per i due campi (unica eccezione
concordata al confine dei lotti, righe ~37-60) distingue "non inviato" da
"inviato null" con model_fields_set, invece di getattr(...) is not None —
altrimenti un null esplicito veniva scartato allo stesso modo di un campo
assente e il valore restava bloccato per sempre. Gli altri campi header
(name, description, requires_*, allow_manual_input) non toccati.
Test backend in test_recipes.py: TestUpdateRecipeClearNullableFields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:05:51 +02:00
Adriano 72f0cb1f1b fix(frontend): #37 punti 1,4,5,6 - 502 backend giù, barcode quotato, apici Alpine, paginazione ricette
1. api_client.py: ConnectionError/Timeout restituisce status_code=502 invece
   di 0 (nessuna route confrontava == 0, verificato); i proxy che inoltrano
   resp.get("status_code", 500) ereditano il fix senza altre modifiche.
4. measure.py lookup_barcode: code non stringa -> 400 (prima 500 su
   code.strip()); urllib.parse.quote(code, safe="") prima di comporre l'URL
   verso il backend - un barcode con '/' o '../' non spezza più il path né
   raggiunge altri GET con i permessi dell'utente.
5. Ogni "'{{ _(...) }}'" dentro un attributo Alpine (x-text, :title,
   :placeholder, @click) sostituito con "{{ _(...)|tojson_attr }}": la
   traduzione EN di admin/stations.html:105 ("Clear the station's recipes")
   ha un apostrofo che l'IT non ha, e rompeva quel binding e basta - non è
   un XSS, ma stessa classe di difetto di #10. File toccati: admin/stations,
   admin/users, maker/recipe_list, maker/recipe_editor, maker/task_editor,
   maker/task_drawing, components/caliper_status, measure/select_recipe.
   Le occorrenze dentro <script>...</script> (non attributi) sono fuori
   perimetro per questo punto.
6. statistics.py dashboard(): ciclo sulle pagine (per_page=100, il massimo
   del backend) invece di un'unica chiamata che troncava la tendina ricette
   alla centesima.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:03:06 +02:00
Adriano c0583da478 fix(csv): escapeCsvValue neutralizza formula injection, quota anche \r (#22)
Stessa regola del backend (neutralise_csv_cell): un valore che, tolti gli
spazi iniziali, comincia con =, +, -, @, TAB o CR riceve un apice davanti
prima di finire in cella - Excel/LibreOffice lo trattano come testo invece
che come formula. Solo per campi testuali (lot/seriale/descrizione):
formatNumber, usato per le colonne numeriche, non passa da qui, quindi un
valore negativo resta un numero. Aggiunto anche il quoting per \r, che
prima passava non quotato.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:53:36 +02:00
Adriano e8d21b78a9 fix(csv): export dal browser legge nominale/tolleranze/descrizione da m.subtask (#31)
buildMeasurementCSV leggeva m.subtask_name/unit/nominal/tolerance_plus/
tolerance_minus/operator_name: nessuno di questi campi esiste su
MeasurementResponse. I dati reali stanno annidati in m.subtask (description,
unit, nominal, utl, ltl - misurati.py:task_complete() li allega lato server).
tolleranza + = utl-nominal, tolleranza - = nominal-ltl, solo quando entrambi
i lati sono presenti (nessun NaN su tolleranza a un lato solo). Unità dalla
quota, "mm" solo se assente. Operatore: measured_by (id), l'unico campo
reale - non esiste un nome operatore da nessuna parte in questi dati.
Test node in tests/js/test_csv_export.js (stesso pattern di test_numpad.js).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:52:21 +02:00
Adriano 4384c4860b docs(.env.example): APP_ENV e SETUP_ALLOW_AFTER_INIT (#33, #9)
Righe mancanti per le impostazioni backend introdotte in questo lotto:
APP_ENV (guardia sui segreti di default, #33) e SETUP_ALLOW_AFTER_INIT
(override del blocco post-init di setup.py, #9). TRUSTED_PROXY_COUNT e
WEB_CONCURRENCY erano gia' state aggiunte da un altro lotto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:50:11 +02:00
Adriano 1bb00b14f2 fix(measure): riepilogo fine task filtrato per lotto/seriale/operatore, pagina oltre 500 (#30)
task_complete(): GET /api/measurements ora porta lot_number/serial_number
(sessione) + measured_by (utente corrente), invece del solo version_id -
una seconda produzione sulla stessa versione non pesca più le misure di
altre stazioni/giorni. Risposte oltre 500 misure vengono cicliche per
pagina invece di troncate al primo per_page=500.
production_run_id sarebbe il filtro più preciso ma GET /api/measurements
non lo accetta (lotto A3) e task_execute.html non lo passa in query
(lotto B1): MANCA, annotato nel codice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:49:59 +02:00
Adriano e20299548f fix(migrations): schema base 000 per DB vuoti, stamp head dopo create_all (#6)
upgrade head partiva dalla 001, che sa solo aggiungere colonne a tabelle
che assume gia' esistenti: su un DB davvero vuoto falliva subito. Nuova
000_base_schema.py (revision "000_base", down_revision None) crea lo
schema pre-001 esplicitamente (create_table, non Base.metadata.create_all,
che porterebbe gia' le colonne di 001-013 e farebbe fallire 001 su
"Duplicate column"). No-op se la tabella recipes esiste gia'
(sa.inspect(bind).has_table("recipes")). 001 ora ha down_revision =
"000_base".

setup.py: init-db e reset-db, dopo create_all/drop_all+create_all,
eseguono l'equivalente di "alembic stamp head" (nuovo _stamp_head(),
command.stamp con la config di src/backend/migrations/alembic.ini) cosi'
un DB nato da create_all non ritenta le migrazioni 001-013 al prossimo
deploy.

Test: catena upgrade head su SQLite vuoto, tabelle/colonne confrontate
con Base.metadata; DB con recipes gia' nello stato pre-001 -> upgrade
head non fallisce (000 no-op). _stamp_head si appoggia a
migrations/env.py, che costruisce una connessione propria da
settings.database_url (non modificabile dai test tramite l'engine
patchato di setup.py): nei test di setup.py e' quindi no-op via
monkeypatch, cosi' init-db/reset-db continuano a girare su SQLite senza
toccare un MySQL vero.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:49:38 +02:00
Adriano 805f41c757 fix(upload): tipo da magic bytes, non da Content-Type dichiarato (#36 punto 2)
Il Content-Type del client non era verificato: un file "x.html" dichiarato
image/png veniva salvato e poi servito per estensione come text/html,
eseguito dal browser. Nuova detect_file_type() (PNG/JPEG/GIF/WebP/PDF) in
files.py, usata anche dal logo in settings.py; l'estensione salvata segue
sempre il tipo rilevato, mai il nome file dichiarato dal client. Il logo
rifiuta SVG a prescindere (puo' portare script). Upload letti a blocchi da
1MB, interrotti appena superano il limite invece di bufferizzare tutto
prima di controllare la dimensione.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:48:52 +02:00
Adriano bfe66a5710 fix(production): ruolo e assegnazione stazione su apertura/avanzamento run (#24 backend)
api/routers/production.py: open/cycle/remeasure/pause/resume/close accettavano
qualunque utente autenticato (get_current_user). Nuova dipendenza locale
_require_run_operator (MeasurementTec o Supervisor o admin) sui sei endpoint
di ciclo vita; le letture (current, {run_id} GET) restano aperte a tutti gli
autenticati. Per pause/resume/close si aggiunge al controllo esistente delle
credenziali del supervisore in corpo, che riguarda un'altra persona (chi
autorizza), non chi opera.

services/production_service.py open_run: nessun controllo su recipe.active ne'
sull'assegnazione ricetta-stazione. Nuovo _require_recipe_assigned_to_station:
se la stazione ha almeno un'assegnazione (StationRecipeAssignment), la ricetta
deve esserci, altrimenti 409; una stazione senza alcuna assegnazione resta
libera - stessa regola del filtro in lettura di station_service.list_station_recipes
(join che senza righe non filtra nulla). Ricetta non active -> 409.

NON TOCCATO: src/frontend/flask_app/blueprints/measure.py (stesso filtro sul
lato Flask, lotto B2) e test_measure_station_filter.py - altro agente ci
lavora in parallelo sulla stessa cartella.

Test: src/backend/tests/test_production_runs.py, 6 nuovi test (ruolo su
open/pause, assegnazione rifiutata/accettata/libera, ricetta inattiva).
PROVA: uv run --extra server --extra dev pytest src/backend/tests/test_production_runs.py -q -> 34 passed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:48:17 +02:00
Adriano 77d95fdcc4 fix(tasks): copy-on-write su modifica/cancellazione e ordine tolleranze (#4, #26)
#4 - api/routers/tasks.py: update_task, delete_task, create_subtask,
update_subtask, delete_subtask controllavano solo is_current, non
version_has_measurements: la versione con misure veniva modificata sul posto,
cambiando i limiti di quote gia' giudicate pass/fail. Nuovi helper
_task_for_write / _subtask_for_write riusano recipe_service.create_new_version
(lo stesso copy-on-write di create_task): se la versione ha misure creano una
nuova versione, ritrovano il task per order_index e la quota per marker_number
(chiavi che _copy_tasks_to_version preserva), e la scrittura va sulla copia.
La versione misurata resta intatta.
Trovato e corretto nello stesso punto: il task ritrovato nella copia non ha
.version precaricato (new_version.tasks viene da selectinload, che non popola
il backref figlio->genitore), e TaskResponse.recipe_id legge task.version.recipe_id
- senza assegnare esplicitamente candidate.version = new_version, update_task
va in MissingGreenlet (lazy load fuori da un await) invece di rispondere 200.

#26 - models/api/task.py: nuovo validate_tolerance_order (ltl<=lwl<=nominal<=
uwl<=utl sui soli campi valorizzati), usato come model_validator su
SubtaskCreate e richiamato dal router in update_subtask sui valori risultanti
(esistenti + parziali) prima del flush, perche' SubtaskUpdate non ha un insieme
fisso di campi da validare da solo.

Test: src/backend/tests/test_tasks.py, TestCopyOnWriteOnEdit (5) e
TestToleranceOrder (4); adattato test_update_subtask esistente (nominal 12.0
violava il nuovo ordine contro utl=10.5 invariato).
PROVA: uv run --extra server --extra dev pytest src/backend/tests/test_tasks.py -q -> 16 passed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:48:08 +02:00
Adriano 7b015078e9 fix(measurements): value NaN/Inf/scala e coerenza quota/versione/run (#5, #20)
#20 - models/api/measurement.py: value accettava NaN/Infinity/fuori scala,
ogni confronto tornava falso ("pass") e poi l'INSERT falliva. Il controllo
sposta in measurement_service._validate_measured_value (math.isfinite +
|value|<1e6): un Field pydantic (allow_inf_nan/gt/lt) rifiuta la stessa cosa
ma FastAPI, costruendo l'errore 422, riecheggia il NaN nel campo "input" e
Starlette lo nega con allow_nan=False, producendo un 500 al posto del 422 -
verificato con una chiamata reale, non solo a livello di modello.
calculate_pass_fail e save_measurement arrotondano value a 6 decimali (stessa
scala di DECIMAL(12,6)) prima di calcolare e salvare, cosi' il verdetto e il
valore persistito non divergono mai.

#5 - measurement_service.save_measurement: prima dell'insert verifica che la
quota appartenga a un task della version_id dichiarata (422 altrimenti), e se
production_run_id e' dato usa la nuova validate_run_for_version(db, run_id,
version_id): il run deve esistere (404), essere della stessa ricetta della
versione (422), essere misurabile - riusa production_service._require_measurable
(409 se chiuso o in pausa). Import locale per evitare un ciclo con
production_service, che gia' importa measurement_service.

Test: gia' presenti in test_measurements.py (TestMeasurementValueBounds,
TestSaveMeasurementCoherence) - finiti nel commit 0dce229 di un altro agente
che lavorava in parallelo sullo stesso file (export CSV, #22); confermato con
`git show 0dce229 -- test_measurements.py`, contenuto intatto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:47:58 +02:00
Adriano befdce5e6a fix(measure): filtro stazione anche su barcode, avvio sequenza e task_execute (#24)
_recipe_allowed_at_station() riusa get_station_recipes() (stessa fonte di
/measure/select) per lookup_barcode, start_sequence e task_execute: prima
solo l'elenco filtrava, il barcode e i link diretti lasciavano misurare
ricette non assegnate alla stazione. Ricetta non assegnata -> abort(403)
con messaggio "ricetta non assegnata a questa stazione" (pagine, come già
fa role_required) o JSON 403 (lookup-barcode). Aggiornati i test esistenti
che ora richiedono get_station_recipes/STATION_CODE mockati.
Backend (ruoli sui run, StationRecipeAssignment su open_run) resta al
lotto A1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:47:30 +02:00
Adriano f739f963c7 fix(base): auto-logout ignora le letture del solo calibro (#37 punto 2)
L'inattività contava mousemove/mousedown/keypress/touchstart/scroll. Il
numpad fa preventDefault() su ogni cifra/Enter letta dal calibro USB, il che
sopprime anche il keypress successivo: un operatore che lavora solo di
calibro non generava alcuna attività e veniva disconnesso a metà task.
Aggiunto 'keydown' alla lista: scatta comunque, a prescindere dal
preventDefault() di un handler successivo sullo stesso evento.

Test: rendering di una pagina con auto_logout_minutes impostato, verifica
che l'elenco eventi contenga 'keydown'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:46:45 +02:00
Adriano 31fa78bdfd fix(task_execute): letture doppie, sessione scaduta, ciclo rifiutato dal server (#13, #12, #29, #37)
#13 — handleMeasurement era rientrante: saving tornava false prima della
pausa di 1s di feedback, currentIndex avanzava dopo. Una seconda lettura in
quella finestra salvava due volte la stessa quota e saltava la successiva.
Guardia unica this.busy: true all'ingresso, false su ogni uscita della
funzione; l'ingresso è bloccato anche con showCompletionOverlay,
showSupervisorModal, isPaused o cycleConfirmed pendenti. Una lettura scartata
non sparisce in silenzio: errorMessage "Lettura ignorata: attendere".

#12 — dopo la scadenza del token CSRF (config lato server, lotto B2) il POST
tornava 400 in HTML: response.json() lanciava, finiva nel catch generico e
l'operatore vedeva "Errore di rete" invece che "Sessione scaduta". Aggiunto
try/catch dedicato sul parsing e controllo di result.csrf_expired /
session_expired, sia in handleMeasurement che in postProduction(); la lettura
non salvata resta sul display.

#29 — confirmCycle mostrava il ciclo chiuso anche quando il server rifiutava
/cycle (produzione aperta, postProduction torna null): cadeva nel ramo "nessuna
produzione", incrementando cycleCount in locale. Ora, con produzione aperta, un
rifiuto del server ripristina cycleConfirmed=false, riapre l'overlay di
completamento e mostra l'errore (già in productionError); il ramo locale resta
riservato a quando non c'è produzione aperta.

#37 punto 9 — il blocco capoturno era deciso su passFailStatus (preview
client, prima del salvataggio) invece che su result.pass_fail del server;
ora la risposta del server decide sia il blocco che i conteggi locali.

Completa anche il lato pagina di #27: aggiunto il listener per l'evento
numpad-error (dispatchato da numpad.js) cosi' la lettura rifiutata dal calibro
è visibile all'operatore, non solo intercettata in silenzio.

Stringhe nuove in messages.po (it/en): "Sessione scaduta: ricaricare la
pagina", "Lettura ignorata: attendere", "Lettura del calibro rifiutata: valore
troppo grande".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:46:32 +02:00
Adriano ac11cec276 docs(deployment): riscrive le sezioni con percorsi morti sul layout reale (#35)
Sostituito il vecchio monorepo a due cartelle (server/, client/,
requirements.txt, moduli main:app senza namespace) con uv e il layout
attuale (src/backend, src/frontend/flask_app, tre Dockerfile alla radice).

Database Setup: niente più script Python per l'admin — punta alla pagina
/api/setup e ne descrive la disattivazione dopo il primo avvio. Rimossa
la password d'esempio cablata e il comando che stampava database_url
(stampava anche le credenziali).

Docker Deployment: docker compose v2 (non più il trattino), i tre
Dockerfile reali, submodule vendor/visionsuite e VISION_ENGINE_VERSION
obbligatoria alla build. Recommended Architecture/SSL: due percorsi
reali (Traefik con TLS automatico in produzione, Nginx in HTTP semplice
per l'on-premise su LAN), non più un nginx generico con certificato a
mano. Tabella env aggiornata con le variabili introdotte dalla revisione
del 21/09/2026 (APP_DOMAIN, TRUSTED_PROXY_COUNT, WEB_CONCURRENCY,
SESSION_COOKIE_SECURE, VISION_RUN_TIMEOUT_S, VISION_MAX_CONCURRENCY).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:45:55 +02:00
Adriano 3e99b64fc4 fix(users): roles/is_admin null -> 422, admin non si toglie da solo (#36 punto 4)
roles e is_admin sono NOT NULL nel DB ma Optional nello schema: {"roles":
null} o {"is_admin": null} passava la validazione Pydantic e falliva al
flush con un 500 non gestito. Ora 422 esplicito. Aggiunta anche la
protezione sull'ultimo admin: un admin non puo' togliersi is_admin da solo
(409) - unico caso raggiungibile via API dato che solo un admin puo'
chiamare l'endpoint, quindi rimuovere il flag a qualcun altro lascia sempre
il chiamante; il controllo sul conteggio resta comunque come difesa in
profondita'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:45:43 +02:00
Adriano 8864b2021b fix(scripts): guardie di seed_collaudo.py per produzione e cancellazioni (#17)
--supervisor-password non ha piu' un default committato: se il
capoturno va creato e l'opzione manca, ne genera una casuale
(secrets.token_urlsafe(12)) e la stampa una sola volta.

--conferma-produzione richiesto quando APP_ENV (src.backend.config,
stessa regola di #33) non e' dev/test.

--replace elenca quante Measurement/ProductionRun/ProductionEvent
cancellerebbe e si ferma senza cancellare finche' non si aggiunge --si.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:45:14 +02:00
Adriano 54bc384650 fix(vision): task_id di vision_results/vision_reference_images in CASCADE (#38.6)
Migrazione 014 (batch_alter_table, nome del vincolo esistente letto con
sa.inspect invece di indovinato) e stessi ondelete nei modelli ORM.
Cancellare un task con esecuzioni o immagini di riferimento non fallisce
più su un vincolo FK MySQL.

MANCA: i file delle immagini di riferimento su disco non vengono
rimossi alla cancellazione del task - tasks.py::delete_task (NON
TOCCARE da questo lotto) cancella con `db.delete(task)` diretto, senza
un punto unico in vision_service da agganciare. `_copy_tasks_to_version`
(recipe_service.py) non copia le immagini di riferimento nel
copy-on-write: nemmeno questo si tocca qui.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:44:55 +02:00
Adriano 403c923188 fix(ai): risposta AI malformata -> 502, non 400 (#36 punto 7)
json.JSONDecodeError e' un ValueError: KeyError/JSONDecodeError sulla
risposta del modello finivano nell'except ValueError del router (pensato
per i nostri errori di configurazione, es. API key mancante) e uscivano
come 400 invece di 502. Nuova AIResponseError dedicata in ai_service.py
per contenuto malformato o JSON non valido; recipes.py la traduce in 502,
toccato solo per questo except.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:43:42 +02:00
Adriano 65f6a4b18b feat(vision): immagini di riferimento non orfane, GET/DELETE (#38.5)
save_reference_image scrive il file dopo il flush riuscito; il router
lo cancella se il commit successivo fallisce. Aggiunti GET
/api/vision/reference-images?task_id= (lista, Maker) e DELETE
/api/vision/reference-images/{id} (cancella riga e file, Maker).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:43:35 +02:00
Adriano 5ca3dc14cd fix(logging): action troncata a 100, ip_address dal client IP proxy-aware (#36 punto 6)
action era action=f"{method} {path}" senza limite: un path lungo falliva
l'INSERT (String(100)), inghiottito dal try/except generico, e la riga di
audit spariva in silenzio. Troncata a 100 caratteri. ip_address usava
request.client.host, che dietro Nginx/Flask e' sempre il proxy: ora usa
RateLimitMiddleware._client_ip(request), lo stesso risolutore proxy-aware
del rate limiter (X-Forwarded-For con trusted_proxy_count).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:42:16 +02:00
Adriano a2f357b3d3 fix(config): rifiuta i segreti di default fuori da dev/test (#33, parte A2)
Nuova impostazione app_env (env APP_ENV, default "dev"). All'istanza di
Settings, se app_env non è "dev"/"test" e SERVER_SECRET_KEY o
DB_PASSWORD sono ancora il valore committato, RuntimeError che nomina
la variabile. Dev e test continuano a partire senza .env: il default
"dev" non fa mai scattare la guardia.

Le parti frontend (flask_app/config.py) e deploy (docker-compose.yml)
di questa issue sono degli altri lotti (B2, C2).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:41:28 +02:00
Adriano 0ec5988581 docs(readme,claude): Quick Start ricostruibile — sottomodulo e VISION_ENGINE_VERSION (#18)
README Quick Start: clone con --recurse-submodules (nota sulla chiave SSH per
git.tielogic.xyz:222), VISION_ENGINE_VERSION valorizzata prima di up --build,
comando già descritto più sotto nel documento e ora portato qui.

CLAUDE.md: titolo del layout V2.0.0 -> V3.0.0, aggiunti src/vision,
src/vision_worker, vendor/visionsuite all'albero; nuova sezione «Visione
(V3.0.0)» con sottomodulo, variabile, worker Python 3.13, comando dei test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:41:23 +02:00
Adriano 1c5292d1e0 fix(vision): execute controlla task_type, versione, ricetta attiva, produzione (#38.7)
execute_task rifiuta 422 se il task non è camera_measure, 409 se la
versione non è corrente o la ricetta non è attiva; production_run_id,
quando presente, è validato con measurement_service.validate_run_for_version
(stessa regola di una lettura manuale). Router: lot_number/serial_number
max_length=100, production_run_id gt=0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:41:22 +02:00
Adriano c8caf40af9 fix(task_execute): lotto/seriale in |tojson invece di letterale JS (#32)
lotNumber/serialNumber erano '{{ lot_number or '' }}': autoescape HTML dentro
uno <script>, non escaping JS/JSON. '&' diventava '&amp;' e finiva salvato nel
DB a ogni misura e a production/start; un lotto con backslash rompeva la
sintassi JS della pagina. |tojson produce un letterale JSON valido sia come JS
che come HTML, come già fa refuseWhileBlocked() più sotto nello stesso file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:40:26 +02:00
Adriano d40c74870e fix(numpad): segno perso e raffica HID concatenata a cifre manuali (#11, #27)
toggleSign() usciva subito a display vuoto: il segno del calibro, che arriva
prima delle cifre, veniva scartato. Ora '-'/'+' impostano il segno in modo
esplicito (non toggle) quando arrivano da tastiera/HID; toggleSign() (bottone
manuale +/-) funziona anche a vuoto.

_trackBurst riconosce l'inizio di una raffica solo dal secondo carattere:
prima ripuliva il display in ritardo, concatenando cifre digitate a mano.
Ora la raffica ripulisce il display fino al proprio inizio quando la pausa
prima di essa è > 300ms. Parte intera oltre 6 cifre: rifiutata con evento
numpad-error invece di essere troncata in silenzio.

Test node in tests/js/test_numpad.js, lanciato da pytest
(tests/test_numpad_js.py, skip se node manca).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:40:21 +02:00
Adriano 1576fa190b deploy(compose): SESSION_COOKIE_SECURE per l'on-premise su HTTP in LAN (#38)
docker-compose.dev.yml (Nginx senza SSL, l'installazione on-premise):
default false. .env.example: default true documentato, con la spiegazione
di quando va a false. La lettura in src/frontend/flask_app/config.py è
del lotto B2, non ancora landata: qui si documenta e si predispone solo
la variabile lato deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:40:06 +02:00
Adriano ac76c0a395 fix(auth): api_key condivisa fra postazioni, timing costante al login (#25)
login_user riusa l'api_key esistente invece di rigenerarla ad ogni
login: due postazioni collegate con lo stesso utente restavano
altrimenti disconnesse a vicenda. logout_user diventa no-op sulla key
a meno di all_sessions=True (nuovo parametro, passato da
POST /api/auth/logout?all_sessions=true nel router).

authenticate_user esegue comunque bcrypt.checkpw contro un hash fittizio
quando lo username non esiste, per non rivelare col tempo di risposta
se uno username è registrato.

PUT /api/users/{id}/password azzera l'api_key dell'utente dopo il
cambio, come già fa setup.py:634.

Non introdotto in questa issue: hashing o scadenza della api_key
(annotato come MANCA nel rapporto del lotto).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:40:05 +02:00
Adriano 302903a8a7 fix(recipe): task di default per il disegno e' task_type=drawing (#36 punto 5)
update_current_version creava il task di default (nessun task ancora sulla
versione) senza task_type, quindi finiva "note" invece di "drawing" come
fa create_new_version alla riga ~293. Aggiunta la riga mancante.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:39:50 +02:00
Adriano b15037851b fix(vision): /run del worker non blocca più l'event loop, timeout e semaforo (#34)
Decodifica + run_graph girano in asyncio.to_thread sotto asyncio.wait_for
(VISION_RUN_TIMEOUT_S, default 100s, sotto i 120s del backend) -> 504 del
worker allo scadere. /health resta leggero e risponde durante un calcolo
in corso. asyncio.Semaphore(VISION_MAX_CONCURRENCY, default 2): oltre ->
503 "worker busy". vision_service._call_worker inoltra 422/503/504 del
worker con il loro status e motivo invece di appiattirli su 502.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:39:28 +02:00
Adriano b024724c7e chore(release): version 3.0.0 (#38)
pyproject.toml allineato al layout V3.0.0 già in uso da README/CLAUDE.md.
uv lock (senza upgrade): cambia solo la riga della versione del progetto,
verificato col diff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:39:20 +02:00
Adriano 1d5f24510c fix(measurements): export CSV ammette admin senza ruolo esplicito (#36 punto 3)
Il controllo ruolo di GET /api/measurements/export/csv ignorava is_admin:
un admin senza MeasurementTec/Metrologist assegnato prendeva 403, a
differenza di ogni altro endpoint che passa da require_role (ruolo o admin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:39:10 +02:00
Adriano 3f70f99a40 fix(async): chiamate bloccanti in asyncio.to_thread (#36 punto 1)
kaleido (pio.to_image), WeasyPrint (write_pdf), pdfplumber (extract_text_from_pdf)
e la scrittura del CSV di produzione giravano sincrone dentro handler async:
un PDF pesante o un run grande fermava il worker per tutte le altre richieste.
Ora avvolte in await asyncio.to_thread(...) in report_service.py,
ai_service.py e production_export_service.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:38:28 +02:00
Adriano 78970c69b0 fix(setup): confronto costante, blocco dopo l'init, status minimale (#9)
_check_password confrontava la password con !=, ora hmac.compare_digest.

Nuova guardia _require_not_initialised: se esiste già un admin,
reset-db, seed, manage-user, change-user-password e init-db rispondono
403 "setup already completed", a meno di settings.setup_allow_after_init
(nuovo, env SETUP_ALLOW_AFTER_INIT, default False). create-admin,
list-users e toggle-user-active restano fuori dalla guardia.

GET /api/setup/status senza password valida restituisce solo
{"initialised": bool}; l'elenco tabelle solo con la password.

Adeguati i test esistenti che seedavano un admin e poi richiamavano un
endpoint ora guardato (test_setup.py, test_station_seed.py: bypassano
la guardia con setup_allow_after_init quando serve testare altro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:38:09 +02:00
Adriano 9afe1e712b fix(vision): file non immagine e grafo malformato non escono più come 500 (#15)
Worker: Image.open().convert() in try/except (422 "image is not
decodable"); il grafo deve essere un dict (422 altrimenti); run_graph
distingue costruzione (GraphBuildError -> 422) da esecuzione (500 con
motivo). Router: verifica dei magic bytes (PNG/JPEG/BMP/TIFF/WebP/GIF)
prima di chiamare il worker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:37:34 +02:00
Adriano 92f130479a fix(auth): SECRET_KEY di default rifiutato, session.permanent, logout solo POST (#33)
config.py: guard_default_secret_key, verificata a ogni richiesta (prima di
create_app, TESTING/DEBUG non sono ancora impostati dai fixture di test) —
rifiuta con RuntimeError se CLIENT_SECRET_KEY è rimasto al default committato
fuori da debug/testing. auth.py: session.permanent=True al login (senza,
PERMANENT_SESSION_LIFETIME non aveva mai effetto); /auth/logout solo POST,
con CSRF. Aggiornati i tre punti che lo chiamavano: profile.html e
navbar.html (link -> form POST), auto-logout JS in base.html (fetch POST +
redirect al login).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:37:23 +02:00
Adriano e8a8aeed66 fix(files): un Maker non cancella statistics/reports/logo, GET ristretto (#21)
DELETE /api/files/{path} non aveva nessun legame con ricetta/proprietario:
un Maker poteva cancellare qualunque file sotto uploads, compresi i CSV di
statistica di produzioni altrui. Ora: statistics/, reports/, logos/ sono
403 sempre (il logo si cambia da PUT /api/settings/logo); un file di
ricetta (task.file_path, subtask.image_path, recipe.image_path) e' 409 se
referenziato da una versione non corrente o che ha gia' misurazioni - un
file non referenziato resta cancellabile. GET di statistics/* e reports/*
richiede Metrologist, Supervisor o admin (prima bastava essere autenticati).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:36:49 +02:00
Adriano 20957e517d fix(vision): NaN/inf/bool di uno strumento non diventano più una quota (#16)
runner.run_graph esclude bool e NaN/inf dagli output numerici, con il
motivo in RunOutcome.invalid_outputs. vision_service tratta una quota
mappata con valore None/assente/non finito come "non prodotta" (422),
invece di arrivare a float(None) e sollevare TypeError.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:35:53 +02:00
Adriano 10261b66f0 deploy(compose): volume uploads in sola lettura nel container vision (#38)
Il worker di visione legge le immagini di riferimento già caricate dal
server, non le scrive. Montato :ro su entrambi i compose (prod e dev).
GIF/WebP animati nelle miniature: MANCA, decisione di prodotto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:35:44 +02:00
Adriano 7a21ec4e44 deploy(traefik): label Traefik v3, dominio da APP_DOMAIN, middleware anche su tmflow-api (#38)
Punto 11: rimossi SSLRedirect e browserXSSFilter (non esistono più in
Traefik v3), aggiunto frameDeny; rule di entrambi i router da
${APP_DOMAIN:?manca APP_DOMAIN}, non più il dominio cablato. Middleware
tmflow-headers ora applicato anche al router API, non solo al web.

Resto del punto 10: rimosso il PathPrefix(`/uploads/`) dalla rule
dell'API — stesso motivo della location nginx già tolta: nessun mount,
i file passano da /api/files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:35:25 +02:00
Adriano e91ef739af fix(rate-limit): secchio login esteso a credenziali supervisore e setup (#8)
Il secchio "login" del rate limit valeva solo per /api/auth/login. Un
capoturno può ora essere attaccato a forza bruta anche via
/api/measurements/{id}/authorise e /api/production-runs/{id}/pause|
resume|close, senza mai toccare il bucket più permissivo "general".
_is_login_bucket_request confronta il path con un'espressione regolare,
non con ==, perché l'id in mezzo varia per richiesta.

Include anche la parte di #9 che riguarda questo stesso file: i path
sotto /api/setup/ entrano nello stesso secchio, indipendentemente dal
metodo (GET /api/setup/status accetta ora una password opzionale).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:34:42 +02:00
Adriano 1921bc3302 deploy(nginx): location statiche limitata a /static/, header ripetuti, rimossa /uploads/ morta (#38)
La regex delle estensioni statiche ora richiede il prefisso /static/, non
qualunque path con quell'estensione. Gli header di sicurezza del blocco
server non si ereditano in un location che ha un proprio add_header
(comportamento nginx): ripetuti lì. Rimossa location /uploads/: verificato
che il backend non la monta, i file passano da /api/files (files.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:34:14 +02:00
Adriano 67392a946c fix(vision): worker down o in timeout non esce più come 500 nudo (#14)
_call_worker distingue httpx.TimeoutException (504) dal resto dei
TransportError/HTTPError (502, RemoteProtocolError incluso). Un 422 del
worker si propaga come 422 con il motivo del worker, non più 502 generico.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:34:06 +02:00
Adriano a330f2b340 fix(rate-limit): non fidarsi del primo hop di X-Forwarded-For (#7)
_client_ip usava il primo elemento di XFF, impostabile dal client. Ora
usa l'elemento a -trusted_proxy_count (nuova impostazione, default 1,
env TRUSTED_PROXY_COUNT), quello aggiunto dal proxy fidato più esterno.
Con trusted_proxy_count=0 XFF e X-Real-IP sono ignorati.

Aggiunta anche web_concurrency (env WEB_CONCURRENCY, default 1): i
bucket del rate limit sono per processo, con --workers N il limite
configurato viene diviso per N (arrotondato per eccesso, minimo 1) in
_effective_limit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:34:02 +02:00
Adriano 1fee29358f fix(auth): 401 dal backend svuota la sessione e segnala session_expired (#25)
api_client._handle_response: su 401 pulisce la sessione Flask e marca
g.session_expired. Un solo after_request (app.py) trasforma le pagine HTML
rimaste in piedi con un errore generico in un redirect al login con flash;
le route JSON/AJAX che già inoltrano il dict di api_client (measure.py,
statistics.py) ricevono session_expired nel corpo insieme al 401.
Backend (riuso della key, bcrypt a tempo costante) resta al lotto A2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:33:40 +02:00
Adriano ac9bc14f5d deploy(compose): healthcheck su server e client, depends_on condizionati (#38)
server: healthcheck su /api/health. client: healthcheck su /auth/login
(pagina di login, raggiungibile senza sessione). depends_on passa a
condition: service_healthy dove dipende da server/client (client->server,
nginx->server+client in dev). Aggiunge WEB_CONCURRENCY=4 e
TRUSTED_PROXY_COUNT=1 nell'environment del server (li legge il backend).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:33:32 +02:00
Adriano 0dce2299d0 fix(csv): neutralizza injection di formule nei campi testuali export (#22)
lot_number, serial_number (export manuale e di produzione), station.code,
recipe.code, subtask.description finivano nel CSV cosi' come arrivavano:
un lotto '=HYPERLINK(...)' apriva un link vivo in Excel/LibreOffice. Nuova
neutralise_csv_cell() in production_export_service.py, importata da
measurements.py: se il campo (spazi iniziali esclusi) comincia con =, +, -,
@, TAB o CR antepone un apice, cosi' resta testo letterale. Applicata solo
ai campi testuali, mai ai numeri.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:33:32 +02:00
Adriano d1833ba203 deploy(docker): utente non root e versioni base pinnate (#38)
Punto 1: useradd -u 10001 app, chown /app (+ /app/uploads sul server),
USER app in tutti e tre i Dockerfile. Stesso UID sul server e sul worker
di visione, che condividono il volume uploads in sola lettura (punto 13).

Punto 2: python:3.11.16-slim (server, client), python:3.13.15-slim
(vision), ghcr.io/astral-sh/uv:0.10.9 (versione locale di `uv --version`).
Dockerfile.frontend: TailwindCSS ora si compila in uno stage separato
dall'immagine ufficiale node:20.20.2-bookworm-slim, non più con
`curl | bash` su nodesource nell'immagine finale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:32:18 +02:00
Adriano 925ed196dd fix(csrf): token valido quanto la sessione, errorhandler JSON per fetch (#12)
WTF_CSRF_TIME_LIMIT=None: la pagina di misura resta aperta ore, il token a
1h scadeva a metà turno. Aggiunto errorhandler(CSRFError) in app.py: risposte
JSON/fetch (Accept, Content-Type, X-Requested-With) ricevono 400 JSON con
csrf_expired, le altre un flash + redirect. Lato B1 (task_execute.html) resta
da gestire client-side, fuori da questo commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:30:39 +02:00
Adriano 48089bcba6 chore(repo): rimuove src/backend_temp/requirements.txt (#38)
Residuo pre-monorepo, dipendenze già in pyproject.toml (extra server).
Nessun riferimento altrove nel repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:30:20 +02:00
Adriano a6d555d07a deploy(gitignore): uploads/* con eccezione .gitkeep, non più l'elenco (#38)
Copre anche le sottocartelle create a runtime (vision reference images,
cartelle per ricetta/versione), non solo le quattro elencate a mano.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:30:13 +02:00
Adriano ea9da34899 fix(settings): valida csv_delimiter/decimal_separator/auto_logout_minutes (#23)
PUT /api/settings/ non validava nulla: un csv_delimiter vuoto o di piu'
caratteri arrivava intatto nel DB e faceva fallire csv.writer con TypeError
dentro close_run (500, run mai chiuso). Aggiunta validazione per chiave nota
(csv_delimiter 1 carattere fra , ; TAB |, csv_decimal_separator ',' o '.',
auto_logout_minutes vuoto o intero 1-480, company_name/company_logo_path
non vuoti) -> 422 sui valori fuori dominio. Chiavi non note passano senza
validazione (store generico).

Difesa in profondita' in production_export_service._csv_format: un
csv_delimiter gia' sporco nel DB (scritto prima di questa validazione) non
fa piu' fallire close_run, usa ';' e logga un WARNING.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:29:35 +02:00
Adriano 51f3fe32d4 fix(xss): dati query in x-data passano da tojson_attr, non interpolazione JS (#10)
select_recipe.html e recipe_list.html interpolavano recipe/lot/serial/search
dentro un letterale JS con apici singoli: un valore con `'` chiudeva la
stringa e iniettava chiavi nell'oggetto x-data (CSP con unsafe-eval).
Anche recipe_list.html:222 (toLocaleDateString) aveva lo stesso schema su
current_language. Fix: |tojson_attr ovunque.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:29:05 +02:00
Adriano 920ab2fb73 deploy(compose): segreti obbligatori in produzione, senza default (#33)
docker-compose.yml: DB_ROOT_PASSWORD, DB_PASSWORD, SERVER_SECRET_KEY,
CLIENT_SECRET_KEY senza default, `config` si rifiuta se mancano.
docker-compose.dev.yml resta con i default (sviluppo). .env.example
documenta l'obbligo in produzione.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:28:50 +02:00
Adriano 7249cb8a8c fix(spc): Cp/Cpk usa sigma within (MRbar/1.128), Pp/Ppk la stdev campionaria (#19)
Prima Cp/Cpk usava stdev campionaria e Pp/Ppk la population stdev: stesso
indice due volte, proporzionale. Ora Cp/Cpk (capability a breve termine)
usa sigma_within dalla moving range dei valori consecutivi in ordine
cronologico; Pp/Ppk (performance complessiva) resta sulla stdev campionaria
(n-1). MRbar == 0 -> Cp/Cpk None. Stessa sigma_within per UCL/LCL della
carta di controllo a valori individuali (mean +/- 2.66*MRbar), non piu la
stdev globale che allargava i limiti e nascondeva la deriva.

Aggiunto campo opzionale std_dev_within a CapabilityData.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:27:06 +02:00
Adriano 8cc5e8266a docs: il capitolo 3 dichiara che il buco e' colmato, e dove
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JyL1sTGpFxx7Qw3xP58eoZ
2026-09-14 12:46:41 +02:00
Adriano 665d25e574 docs: la V1 e' fatta, e due cose che la specifica dava per buone non reggevano
Il ponte e' in VisionSuite (6f5990c, issue #2 chiusa). Corretti due punti di
questo documento: PyTorch in vs-dxf non c'e' piu' da L2, quindi quel lavoro
non esisteva; e la posa iniziale non serve, misurato — l'ICP converge da solo
fino a 45 gradi. Aggiunta la trappola dell'angolo ambiguo sui pezzi
simmetrici, che e' esattamente il caso dei profilati di Trafilo, e il limite
degli ingressi di scansione non collegabili.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JyL1sTGpFxx7Qw3xP58eoZ
2026-09-14 12:45:59 +02:00
Adriano 401457d21f docs: in fiera ci va anche la V2, e il grafo si compone con un editor a nodi
Decisione A chiusa il 14/09, e revoca la revoca del 09/08: allora il
committente era Trafilo e voleva misurare profilati, adesso il modulo e
un investimento e allo stand guarda chi deve immaginarci il proprio pezzo.
Il taglio per la fiera arriva fino alla misura vera; expected_json resta
dopo. La V3, che e cio che Trafilo ha pagato, scala indietro.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JyL1sTGpFxx7Qw3xP58eoZ
2026-09-14 10:29:26 +02:00
Adriano 4adad9ab9b docs: il modulo di visione è un investimento Tielogic, non una voce di commessa
Decisione F chiusa da Adriano il 14/09: non lo paga nessuno, serve a
prendere lavori futuri. Ne discende che l'unica scadenza del modulo è la
fiera, che Tràfilo va servito col perimetro venduto, e che le ore vanno
separate per non falsare il margine della commessa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JyL1sTGpFxx7Qw3xP58eoZ
2026-09-14 09:59:51 +02:00
Adriano f930fd98f0 docs: stato verificato della visione su V3.0.0 e specifica di cosa manca
Verificato sul codice, non sui piani: le fondamenta del 16-17/08 ci sono
tutte, ma pattern matching e DXF compare non sono eseguibili dal grafo —
vs-task registra dieci strumenti, tutti geometrici, e vs-dxf non è
nemmeno negli extra. Il ponte va costruito in VisionSuite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JyL1sTGpFxx7Qw3xP58eoZ
2026-09-14 09:18:11 +02:00
Adriano 39a6f62089 fix(test): prima parte vuol dire tracciato, non fuori da vendor
Il controllo sui riferimenti esterni scandiva anche i file che git non
conosce. Una copia di comodo di una libreria lasciata in static/js/ --
fabric-debug.js -- sembrava codice nostro e faceva fallire la prova con gli
URL nei propri commenti, ma non sta nel repository e in reparto non ci
arriva mai. Su un checkout pulito la prova passava: falliva solo su chi
aveva quel file, cioe' proprio su chi stava lavorando.

Il filtro ora e' la tracciabilita', non il percorso. Non i pattern di
.gitignore, che non dicono nulla su un file gia' tracciato. Se git non
risponde si scandisce tutto come prima: una prova-guardia che ammutolisce
quando perde l'appoggio e' peggio di una che grida al lupo.

Verificato che morda ancora: un fetch verso un CDN in un file tracciato la
fa fallire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-17 11:02:40 +02:00
Adriano f20b92bafb test(vision): la suite non lascia piu' immagini nel repository
Il file di test dei limiti di upload scriveva un jpeg vero in
uploads/vision/reference/ a ogni esecuzione: gli mancava la fixture di
dirottamento che test_vision_reference.py ha gia'.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tre scelte di modello:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:41:57 +00:00
330 changed files with 56770 additions and 3962 deletions
+57
View File
@@ -3,6 +3,8 @@
# =========================== # ===========================
# --- Database --- # --- Database ---
# DB_PASSWORD e DB_ROOT_PASSWORD (sezione Docker sotto): in produzione (docker-compose.yml)
# sono obbligatorie, senza default — `docker compose config` si rifiuta se mancano.
DB_HOST=localhost DB_HOST=localhost
DB_PORT=3306 DB_PORT=3306
DB_NAME=tiemeasureflow DB_NAME=tiemeasureflow
@@ -10,12 +12,20 @@ DB_USER=tmflow
DB_PASSWORD=change_me_in_production DB_PASSWORD=change_me_in_production
# --- Server --- # --- Server ---
# APP_ENV: "dev" (default) o "test" fanno partire il server senza controlli;
# qualunque altro valore (es. "production") fa scattare i rifiuti descritti
# sotto per SERVER_SECRET_KEY e DB_PASSWORD ancora ai valori committati.
APP_ENV=dev
# SERVER_SECRET_KEY: in produzione obbligatoria, senza default, e il server rifiuta
# di avviarsi se resta uguale al valore committato qui sotto.
SERVER_HOST=0.0.0.0 SERVER_HOST=0.0.0.0
SERVER_PORT=8000 SERVER_PORT=8000
SERVER_SECRET_KEY=change-this-to-a-random-secret-key SERVER_SECRET_KEY=change-this-to-a-random-secret-key
SERVER_CORS_ORIGINS=http://localhost:5000 SERVER_CORS_ORIGINS=http://localhost:5000
# --- Client --- # --- Client ---
# CLIENT_SECRET_KEY: in produzione obbligatoria, senza default, e il client rifiuta
# di avviarsi se resta uguale al valore committato qui sotto.
CLIENT_HOST=0.0.0.0 CLIENT_HOST=0.0.0.0
CLIENT_PORT=5000 CLIENT_PORT=5000
CLIENT_SECRET_KEY=change-this-to-another-random-secret-key CLIENT_SECRET_KEY=change-this-to-another-random-secret-key
@@ -24,6 +34,25 @@ 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
# Cookie di sessione "Secure": di default segue FLASK_DEBUG (sicuro salvo
# debug). Impostare a false solo per l'installazione on-premise che serve
# HTTP in LAN (#38.4) - un cookie Secure su un'origine non-HTTPS viene
# scartato dal browser in silenzio e il login non regge mai.
# SESSION_COOKIE_SECURE=false
# --- Vision ---
# Internal address of the vision worker. Never exposed outside tmflow-net.
VISION_WORKER_URL=http://vision:8100
# The pinned VisionSuite commit, stamped into the vision image at build time
# (Dockerfile.vision ARG). Required: `docker compose build vision` fails
# immediately without it. Set with:
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD)
VISION_ENGINE_VERSION=
# --- File Storage --- # --- File Storage ---
# Resolved against the project root in src/backend/config.py. # Resolved against the project root in src/backend/config.py.
@@ -34,12 +63,40 @@ 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
# Una volta creato un admin, /api/setup/{reset-db,seed,manage-user,
# change-user-password,init-db} rispondono 403. true per un deploy demo/test
# riprovisionato via script: non impostarlo su un'installazione reale.
SETUP_ALLOW_AFTER_INIT=false
# --- AI (OpenRouter) --- # --- AI (OpenRouter) ---
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare
# --- Deploy (introdotte dalla revisione del 21/09/2026) ---
# Dominio pubblico dell'installazione, usato nelle rule Traefik di
# docker-compose.yml (produzione). Obbligatorio, senza default.
APP_DOMAIN=
# Quanti hop di reverse proxy fidati ci sono davanti al server (Nginx o
# Traefik): serve a calcolare l'IP reale del tablet per il rate limiter.
TRUSTED_PROXY_COUNT=1
# Numero di worker Uvicorn del server. Il Dockerfile ne avvia 4 di default
# indipendentemente da questa variabile; impostarla esplicitamente per farla
# coincidere se si cambia il numero di worker nell'immagine.
WEB_CONCURRENCY=1
# Cookie di sessione Flask con flag Secure. true di default (richiede HTTPS,
# come dietro Traefik in docker-compose.yml). Le installazioni on-premise su
# rete LAN in HTTP semplice (docker-compose.dev.yml, Nginx senza SSL) la
# impostano a false, altrimenti il browser non rimanda mai indietro il cookie.
SESSION_COOKIE_SECURE=true
# Timeout (secondi) di un'esecuzione del worker di visione.
VISION_RUN_TIMEOUT_S=100
# Quante esecuzioni di visione in parallelo accetta il worker.
VISION_MAX_CONCURRENCY=2
# #46: radice sotto cui il worker legge le cartelle ricetta (recipe_dir relativo).
VISION_RECIPES_ROOT=/app/uploads
# --- Docker --- # --- Docker ---
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
DB_ROOT_PASSWORD=root_password_change_me DB_ROOT_PASSWORD=root_password_change_me
NGINX_PORT=80 NGINX_PORT=80
NGINX_SSL_PORT=443 NGINX_SSL_PORT=443
+3
View File
@@ -0,0 +1,3 @@
# TieMeasureFlow#81: static del frontend sempre con LF, anche con core.autocrlf=true su Windows:
# le impronte sha256 di static/vendor/VERSIONS.md (test_offline.py) e il pacchetto Windows le vogliono identiche.
src/frontend/flask_app/static/** text=auto eol=lf
+119
View File
@@ -0,0 +1,119 @@
# TieMeasureFlow#76: setup.exe di TieMeasureFlow per Windows, generato al tag v*.
# Cosa fa: submodule vendor/visionsuite (deploy key), build.ps1 (uv sync Python 3.13, Tailwind,
# Nuitka --standalone, migrazioni .pyc, DLL di WeasyPrint, Inno Setup), poi il setup come
# artifact e come asset della release Gitea del tag.
# Da cosa dipende: un runner Gitea con label `windows` che giri su un host Windows (non in
# container) con Visual Studio/Build Tools (MSVC), git e node (serve a actions/checkout e a
# Tailwind); uv e Inno Setup li installa il workflow se mancano. Secret SUBMODULI_SSH_KEY
# (deploy key di sola lettura di TieVision-Core, impostato sul repo il 17/10/2026).
# Il runner `windows` e' act_runner in modalita' host su daitarn (capacity 1).
# ⚠️ upload-artifact@v3: e' la versione che usa gia' vs-native.yml in TieVision-Core su questo
# Gitea; la v4 usa un protocollo che act_runner puo' non supportare.
# ⚠️ L'asset della release usa il token del job (secrets.GITHUB_TOKEN): se la chiamata risponde
# 403 serve un token personale in un secret (es. RILASCI_TOKEN) al posto di GITHUB_TOKEN.
# ⚠️ La build da freddo e' lunga (Nuitka compila backend, visione, plotly, opencv...): timeout 180 min.
name: rilascio-windows
on:
push:
tags: ['v*']
workflow_dispatch: {}
jobs:
setup-windows:
runs-on: windows
timeout-minutes: 180
defaults:
run:
shell: powershell
steps:
# Checkout senza ssh-key (come ci.yml): i submodule si inizializzano a parte, con la deploy key.
- uses: actions/checkout@v4
- name: Submodule privati (deploy key in sola lettura)
env:
SUBMODULI_SSH_KEY: ${{ secrets.SUBMODULI_SSH_KEY }}
run: |
$ErrorActionPreference = 'Stop'
# cartella temporanea del job, non ~/.ssh: su un runner host e' l'account dell'utente
$ssh = Join-Path $env:TEMP ("submoduli-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force $ssh | Out-Null
$chiave = Join-Path $ssh 'chiave'
# LF e newline finale: OpenSSH rifiuta una chiave con CRLF
[System.IO.File]::WriteAllText($chiave, ($env:SUBMODULI_SSH_KEY.Trim() + "`n"), (New-Object System.Text.UTF8Encoding $false))
# OpenSSH su Windows rifiuta una chiave leggibile da altri utenti; (F) e non (R):
# con la sola lettura Remove-Item finale non puo' cancellarla
icacls $chiave /inheritance:r | Out-Null
icacls $chiave /grant:r "$($env:USERNAME):(F)" | Out-Null
$known = Join-Path $ssh 'known_hosts'
[System.IO.File]::WriteAllText($known, "[git.tielogic.xyz]:222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDvABW6Tvruqkf7KJhJZUiAX6NeCx0enwcNXzZbECQ8J`n", (New-Object System.Text.UTF8Encoding $false))
$env:GIT_SSH_COMMAND = "ssh -i $($chiave -replace '\\','/') -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$($known -replace '\\','/')"
try {
git submodule update --init --recursive
if ($LASTEXITCODE -ne 0) { throw "git submodule update fallito" }
} finally {
# la deploy key non resta mai sul disco del runner
Remove-Item $ssh -Recurse -Force -ErrorAction SilentlyContinue
}
- name: uv (se manca)
run: |
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
irm https://astral.sh/uv/install.ps1 | iex
}
# il PATH del passo successivo
"$env:USERPROFILE\.local\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
- name: Node (se manca)
run: |
# serve a Tailwind (npx) in build.ps1
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
winget install --id OpenJS.NodeJS.LTS -e --silent --accept-package-agreements --accept-source-agreements
}
"$env:ProgramFiles\nodejs" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
- name: Inno Setup (se manca)
run: |
$trovato = (Get-Command ISCC.exe -ErrorAction SilentlyContinue) -or
(Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe") -or
(Test-Path "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")
if (-not $trovato) {
winget install --id JRSoftware.InnoSetup -e --silent --accept-package-agreements --accept-source-agreements
}
- name: Build (Nuitka + Inno Setup)
# GITHUB_REF_NAME e' il tag (v0.1.0); con workflow_dispatch e' il ramo: build.ps1 rifiuta
# un nome senza numeri, quindi in quel caso si passa 0.0.0
run: |
$v = '${{ github.ref_name }}'
if ($v -notmatch '^v?\d+\.\d+\.\d+') { $v = '0.0.0' }
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione $v
if ($LASTEXITCODE -ne 0) { throw "build.ps1 fallito" }
- name: Setup come artifact
uses: actions/upload-artifact@v3
with:
name: TieMeasureFlow-setup
path: dist/TieMeasureFlow-setup-*.exe
if-no-files-found: error
- name: Asset della release Gitea (solo ai tag)
if: startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
$ErrorActionPreference = 'Stop'
$h = @{ Authorization = "token $env:TOKEN" }
# la release del tag: la crea il primo job che arriva, poi si riusa
try {
$rel = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG"
} catch {
$corpo = @{ tag_name = $env:TAG; name = "TieMeasureFlow $env:TAG" } | ConvertTo-Json
$rel = Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/json' -Body $corpo "$env:API/releases"
}
$setup = Get-ChildItem dist\TieMeasureFlow-setup-*.exe | Select-Object -First 1
# curl.exe (incluso in Windows 10+) fa il multipart senza codice a mano
curl.exe --fail -sS -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@$($setup.FullName)" "$env:API/releases/$($rel.id)/assets?name=$($setup.Name)"
if ($LASTEXITCODE -ne 0) { throw "caricamento dell'asset fallito" }
+12 -10
View File
@@ -33,16 +33,12 @@ env/
Thumbs.db Thumbs.db
desktop.ini desktop.ini
# Uploads (server-side files, now at project root) # Uploads (server-side files, mounted as a volume in production): ignora tutto
uploads/images/* # sotto uploads/, comprese le sottocartelle create a runtime (vision reference
uploads/pdfs/* # images, cartelle per ricetta/versione) — tieni solo i .gitkeep che mantengono
uploads/logos/* # le cartelle vuote in git.
uploads/reports/* uploads/*
uploads/general/ !uploads/**/.gitkeep
!uploads/images/.gitkeep
!uploads/pdfs/.gitkeep
!uploads/logos/.gitkeep
!uploads/reports/.gitkeep
# TailwindCSS output # TailwindCSS output
src/frontend/flask_app/static/css/tailwind.css src/frontend/flask_app/static/css/tailwind.css
@@ -74,3 +70,9 @@ nul
# Competitor analysis (local only) # Competitor analysis (local only)
Concorrente/ Concorrente/
docker-compose.override.yml docker-compose.override.yml
# Subagent-driven development scratch: ledger, briefs, review packages
.superpowers/
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
packaging/windows/_strumenti.py
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/visionsuite"]
path = vendor/visionsuite
url = ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git
+1
View File
@@ -0,0 +1 @@
*
+58 -12
View File
@@ -7,9 +7,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
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 **backend FastAPI** (porta 8000) e **frontend Flask** (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) ## Layout del repository (V3.0.0)
A partire dalla migrazione V2.0.0 (struttura conforme alla spec `python-project-spec-design.md`): A partire dalla migrazione V2.0.0 (struttura conforme alla spec `python-project-spec-design.md`), con l'aggiunta del modulo di visione in V3.0.0 (`src/vision`, `src/vision_worker`, `vendor/visionsuite`):
``` ```
TieMeasureFlow/ TieMeasureFlow/
@@ -18,10 +18,13 @@ TieMeasureFlow/
├── .python-version # 3.11 ├── .python-version # 3.11
├── Dockerfile # Backend (uv + uvicorn) ├── Dockerfile # Backend (uv + uvicorn)
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel) ├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
├── Dockerfile.vision # Worker di visione (uv, Python 3.13)
├── docker-compose.dev.yml # Dev (Nginx) ├── docker-compose.dev.yml # Dev (Nginx)
├── docker-compose.yml # Prod (Traefik + SSL) ├── docker-compose.yml # Prod (Traefik + SSL)
├── nginx/ ├── nginx/
├── uploads/ # Volume montato in /app/uploads ├── uploads/ # Volume montato in /app/uploads
├── vendor/
│ └── visionsuite/ # Submodule: libreria di visione (vs-core, vs-task, vs-measure, vs-pm2d)
├── docs/ ├── docs/
└── src/ └── src/
├── backend/ ├── backend/
@@ -38,16 +41,18 @@ TieMeasureFlow/
│ ├── migrations/ # Alembic │ ├── migrations/ # Alembic
│ ├── templates/ # Setup page │ ├── templates/ # Setup page
│ └── tests/ # pytest │ └── tests/ # pytest
└── frontend/ ├── frontend/
└── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React, │ └── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
├── app.py # giustificata: tablet UX server-side, USB calipers, │ ├── app.py # giustificata: tablet UX server-side, USB calipers,
├── config.py # workflow operatore con Fabric.js) │ ├── config.py # workflow operatore con Fabric.js)
├── blueprints/ │ ├── blueprints/
├── services/ │ ├── services/
├── templates/ │ ├── templates/
├── static/ │ ├── static/
├── translations/ │ ├── translations/
└── tests/ │ └── tests/
├── vision/ # Runner che esegue i grafi di VisionSuite (Python 3.13)
└── vision_worker/ # Shell FastAPI attorno al runner, container separato
``` ```
Dipendenze gestite con **uv** (no `requirements.txt`): Dipendenze gestite con **uv** (no `requirements.txt`):
@@ -280,6 +285,25 @@ Il server esegue Alembic migrations automaticamente all'avvio (`CMD` include `al
Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120s`, cache statica 7d, security headers duplicati a livello proxy. Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120s`, cache statica 7d, security headers duplicati a livello proxy.
## Visione (V3.0.0)
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 2e73691.
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
```bash
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
docker compose -f docker-compose.dev.yml build vision
```
Test del runner e del worker, in un ambiente Python 3.13 dedicato (con `uv run pytest` di default, su Python 3.11, questi test risultano *skipped*, non falliti):
```bash
uv run --python 3.13 --extra vision --extra vision-worker --extra dev pytest src/vision/tests src/vision_worker/tests
```
## Convenzioni Codice ## Convenzioni Codice
- Python 3.11+, type hints ovunque - Python 3.11+, type hints ovunque
- async/await per tutte le operazioni DB (server) - async/await per tutte le operazioni DB (server)
@@ -311,3 +335,25 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
- Font UI: Inter - Font UI: Inter
- Font numeri: JetBrains Mono - Font numeri: JetBrains Mono
- Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme` - Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme`
## Pacchetto Windows (TieMeasureFlow#76)
- File: `packaging/windows/avvio.py` (entry point: licenza, `config.env`, variabili d'ambiente, migrazioni, quattro server in thread), `build.ps1` (uv sync 3.13, Tailwind, Nuitka `--standalone`, migrazioni come `.pyc`, Inno Setup), `TieMeasureFlow.iss`, `TieMeasureFlow.ico`; CI `.gitea/workflows/rilascio-windows.yml` (tag `v*`, runner con etichetta `windows`); test in `packaging/windows/tests/` (licenza firmata con la chiave di prova del contratto, server e migrazioni sostituiti). Piani collegati: SQLite su file (#77) e PDF/DLL (#78). Modello: TieVision-Studio#1.
- Un solo processo `TieMeasureFlow.exe`: thread `web` (Flask + waitress, `--host`, 8780), `backend` (8000), `vision` (8100), `camera` (8765) con uvicorn. Dati in `%PROGRAMDATA%\Tielogic\TieMeasureFlow` (`tmf.db`, `uploads`, `camera`, `log`, `config.env`); licenza in `%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json` (mai nel repo).
- ⚠️ Python del pacchetto: 3.13 in un ambiente separato (`build\venv313`, `UV_PROJECT_ENVIRONMENT`); il repo resta su `.python-version` 3.11 per lo sviluppo. `vs-license` e `vs-camera` hanno il marker `python_version>='3.13'` nel gruppo `windows`, altrimenti `uv lock` fallisce sul `requires-python >=3.11`.
- ⚠️ Le variabili d'ambiente si impostano PRIMA di importare backend e frontend (leggono la configurazione all'import): in `avvio.py` gli import pesanti stanno dentro le funzioni. `APP_ENV=production` rifiuta i segreti di default, quindi `config.env` genera anche `DB_PASSWORD` (inutilizzata con SQLite).
- ⚠️ Il frontend Flask non è un pacchetto (`from config import Config`, `blueprints`, `services`, `app`) e `scripts/camera_service.py` neppure: da sorgente `avvio.py` aggiunge `flask_app` e `scripts` al `sys.path`; per Nuitka `build.ps1` li mette su `PYTHONPATH` e li include con `--include-module/--include-package`. I dati di Flask (`templates`, `static`, `translations`) vanno nella radice della cartella standalone, `src\backend\templates` sotto `src\backend`.
- ⚠️ Il browser parla con Flask (8780); la pagina di setup `/api/setup` invece è del backend: si apre su `http://127.0.0.1:8000/api/setup` (in Docker la serve nginx). `avvio.py` la apre da sola se il database non ha utenti.
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
- ⚠️ Strumenti di visione (TieMeasureFlow#82): nel binario non ci sono i metadati di `vs-tools`, quindi `load_tools()` (entry point `visionsuite.task.tools`) non trova nulla e ogni ricetta fallisce con tipo sconosciuto. `build.ps1` genera `packaging/windows/_strumenti.py` (`MODULI`, gitignorato, letto dal venv del build), `avvio.py` `registra_strumenti()` lo importa prima dei server, e prima di Inno Setup `build.ps1` lancia l'exe con `--controlla-strumenti` (senza licenza né server; JSON in `...\log\controllo-strumenti.json`, exit 3 se il catalogo è vuoto o un modulo non si importa).
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
- ⚠️ `SETUP_PASSWORD` e le chiavi stanno in `config.env`, in una cartella scrivibile da tutti gli utenti locali (serve all'exe, che gira con l'utente che lo lancia): non è un luogo per segreti ad alto valore.
- ⚠️ CI di rilascio: il runner `windows` è su daitarn (act_runner, modalità host, capacity 1); `upload-artifact` è alla v3. Non committare `build/` e `dist/`.
- ⚠️ `STATION_CODE`: senza una stazione l'interfaccia risponde 503 («Stazione non configurata»). `config.env` nasce con `STATION_CODE=ST-DEFAULT` (la stazione creata dal seed della pagina di setup); le chiavi extra di `config.env` (`STATION_CODE`, `STATION_SWITCH_ENABLED`, ...) passano all'ambiente ma non sovrascrivono le variabili fisse del contratto.
- ⚠️ Alembic anche fuori da `avvio.py`: `setup.py` (`_stamp_head`) apre `alembic.ini` con un `Config` suo, quindi `build.ps1` scrive `sourceless = true` nell'`alembic.ini` del pacchetto; e `env.py` chiama `logging.config.fileConfig`, che azzererebbe il log su file, quindi `avvio.py` lo sostituisce con un no-op.
- ⚠️ Bug noto, non del pacchetto: `POST /api/setup/init-db` risponde 500 (`asyncio.run() cannot be called from a running event loop`, perché `_stamp_head` esegue `env.py` dentro il loop). Nel pacchetto non serve: le migrazioni creano già le tabelle; la pagina di setup si usa per «Seed» o per creare l'amministratore.
- ⚠️ Le richieste di login all'API sono limitate (5 al minuto): negli script di prova distanziarle.
+18 -3
View File
@@ -1,7 +1,7 @@
FROM python:3.11-slim AS base FROM python:3.11.16-slim AS base
# Install uv (fast Python package manager) from official slim image. # Install uv (fast Python package manager) from official slim image.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /usr/local/bin/
# System libs required by WeasyPrint at runtime. # System libs required by WeasyPrint at runtime.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -13,6 +13,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
shared-mime-info \ shared-mime-info \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Non-root user the app runs as at runtime. -m creates a home dir: `uv run`
# in the CMD below writes its cache under $HOME, and without one it has
# nowhere writable to put it.
RUN useradd -m -u 10001 app
# Independent of $HOME regardless: keeps the cache path explicit and writable
# by the app user even if the home directory assumption ever changes.
ENV UV_CACHE_DIR=/tmp/uv-cache
# `uv run` must use the venv built by `uv sync` below as it is: re-validating
# the project would look for vendor/visionsuite (the vision-worker extra), which
# this image does not carry (issue 39). `uv sync` itself is not affected by it.
ENV UV_NO_SYNC=1
WORKDIR /app WORKDIR /app
# Copy lockfile + project metadata first to maximize Docker layer cache. # Copy lockfile + project metadata first to maximize Docker layer cache.
@@ -27,7 +39,10 @@ RUN uv sync --frozen --no-dev --extra server
COPY src/ ./src/ COPY src/ ./src/
# Uploads directory (mounted as a volume in production). # Uploads directory (mounted as a volume in production).
RUN mkdir -p /app/uploads/images /app/uploads/pdfs /app/uploads/logos /app/uploads/reports RUN mkdir -p /app/uploads/images /app/uploads/pdfs /app/uploads/logos /app/uploads/reports \
&& chown -R app:app /app /tmp/uv-cache
USER app
EXPOSE 8000 EXPOSE 8000
+43 -14
View File
@@ -1,13 +1,34 @@
FROM python:3.11-slim AS base # TailwindCSS built in its own stage, from the official Node image, instead
# of `curl | bash` against nodesource inside the final image (point 2, C2).
FROM node:20.20.2-bookworm-slim AS tailwind-build
WORKDIR /app/flask_app
COPY src/frontend/flask_app/ ./
# Versione esatta, non `tailwindcss@3`. Con il vincolo aperto ogni ricostruzione
# dell'immagine poteva prendere una minor diversa e produrre un CSS diverso senza
# che nessuno l'avesse validato: per un sistema che produce evidenze per audit
# ISO 9001 / IATF 16949 è lo stesso problema che il punto 12 risolve per le
# librerie del browser. Aggiornarla è una decisione, non un effetto collaterale.
RUN npm install tailwindcss@3.4.19 && \
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
FROM python:3.11.16-slim AS base
# uv from the official slim image (fast Python package manager). # uv from the official slim image (fast Python package manager).
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /usr/local/bin/
# Node.js 20 is needed at build time to compile TailwindCSS. # Non-root user the app runs as at runtime. -m creates a home dir: `uv run`
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ # in the CMD below writes its cache under $HOME, and without one it has
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ # nowhere writable to put it.
&& apt-get install -y --no-install-recommends nodejs \ RUN useradd -m -u 10001 app
&& rm -rf /var/lib/apt/lists/* # Independent of $HOME regardless: keeps the cache path explicit and writable
# by the app user even if the home directory assumption ever changes.
ENV UV_CACHE_DIR=/tmp/uv-cache
# `uv run` must use the venv built by `uv sync` below as it is: re-validating
# the project would look for vendor/visionsuite (the vision-worker extra), which
# this image does not carry (issue 39). `uv sync` itself is not affected by it.
ENV UV_NO_SYNC=1
WORKDIR /app WORKDIR /app
@@ -16,16 +37,24 @@ COPY pyproject.toml uv.lock ./
COPY .python-version ./ COPY .python-version ./
RUN uv sync --frozen --no-dev --extra client RUN uv sync --frozen --no-dev --extra client
# Copy the Flask app sources. # Copy the Flask app sources, then overlay the TailwindCSS build produced in
# the node stage above — the final image never installs Node.
COPY src/frontend/flask_app/ ./flask_app/ COPY src/frontend/flask_app/ ./flask_app/
COPY --from=tailwind-build /app/flask_app/static/css/tailwind.css ./flask_app/static/css/tailwind.css
# 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. # Compile Flask-Babel translation catalogs.
RUN uv run --project /app pybabel compile -d translations WORKDIR /app/flask_app
RUN uv run --project /app pybabel compile -d translations && \
chown -R app:app /app /tmp/uv-cache
# Commit mostrato nel piede di pagina (#70). .git e' escluso dal contesto di
# build (.dockerignore), quindi lo passa chi costruisce; facoltativo, senza il
# piede mostra solo la versione. Uso:
# APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client
ARG APP_COMMIT=""
ENV APP_COMMIT=${APP_COMMIT}
USER app
EXPOSE 5000 EXPOSE 5000
+53
View File
@@ -0,0 +1,53 @@
FROM python:3.13.15-slim
# opencv-python (pulled in transitively by vs-core/vs-task/vs-measure/vs-pm2d)
# needs libGL and glib at runtime even though it is never imported by name here.
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /usr/local/bin/uv
# Non-root user the app runs as at runtime. Same UID as the server's Dockerfile
# so both containers agree on ownership of the shared uploads volume (point 13).
# -m creates a home dir: `uv run` in the CMD below writes its cache under
# $HOME, and without one it has nowhere writable to put it.
RUN useradd -m -u 10001 app
# Independent of $HOME regardless: keeps the cache path explicit and writable
# by the app user even if the home directory assumption ever changes.
ENV UV_CACHE_DIR=/tmp/uv-cache
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY vendor/ ./vendor/
RUN uv sync --extra vision-worker --frozen --no-dev
COPY src/vision/ ./src/vision/
COPY src/vision_worker/ ./src/vision_worker/
# Ruling R8: the version is stamped at build time, not discovered at runtime.
# COPY vendor/ above does not bring vendor/visionsuite/.git along - it is a
# gitlink file pointing at ../../.git/modules/vendor/visionsuite, which lives
# outside the build context - so `git rev-parse HEAD` cannot work in here.
# Pass the submodule's commit in from the host, which does have git:
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
# docker compose build vision
ARG VISION_ENGINE_VERSION
# I4: a build without the commit must fail now, at build time - not later, at
# every request including /health, with a container Docker still reports as
# healthy because no healthcheck existed to say otherwise.
RUN test -n "$VISION_ENGINE_VERSION" || { \
echo "VISION_ENGINE_VERSION is required. Build with:"; \
echo ' VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) docker compose build vision'; \
exit 1; \
}
ENV VISION_ENGINE_VERSION=${VISION_ENGINE_VERSION}
RUN chown -R app:app /app /tmp/uv-cache
USER app
EXPOSE 8100
# Two workers, not four: each holds the vision stack in memory.
CMD ["uv", "run", "uvicorn", "src.vision_worker.main:app", \
"--host", "0.0.0.0", "--port", "8100", "--workers", "2"]
+231 -17
View File
@@ -2,8 +2,9 @@
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. 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`. > **Versione corrente:** V3.0.0 (in sviluppo) — branch `V3.0.0`.
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md). > Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
> V3.0.0 lavora sui quindici punti di `TieMeasureFlow_modifiche_2026-07-28.md`: vedi [Novità di V3.0.0](#novità-di-v300).
--- ---
@@ -25,6 +26,32 @@ Caratteristiche principali:
--- ---
## Novità di V3.0.0
V3.0.0 lavora sui quindici punti raccolti in `TieMeasureFlow_modifiche_2026-07-28.md`,
dal sopralluogo del 28/07. Quanto segue è **fatto e in esercizio**.
| # | Punto | Cosa cambia |
|---|---|---|
| 1 · 6 | **Produzione con una vita propria** | `production_runs` e `production_events` sul server: avvio, fermo linea, ripresa e fine produzione esistono come stato, non come pulsanti. La fine produzione emette il file di statistica dell'intera produzione |
| 2 | **Tipo di task dichiarato** | `task_type` (`note`, `measure`, `drawing`, `xf_compare`, `camera_measure`) al posto di «ha subtask, quindi è una misura»: un task di misura senza quote non si comporta più come una nota |
| 3 | **Ciclo di misura** | L'intervallo della ricetta vive sul server, sopravvive al cambio pagina, conta anche il ritardo (in rosso, oltre lo zero) e riporta l'operatore alla misura. Cicalino via WebAudio |
| 5 | **Fuori tolleranza vincolante** | Una quota fuori tolleranza blocca l'avanzamento finché il capoturno non autorizza — o finché quella stessa quota non viene rimisurata dentro i limiti. L'autorizzazione resta scritta sulla misura e nel CSV |
| 7 | **Stazioni** | Ricette per stazione, cambio stazione da URL per il collaudo, reset per riga |
| 8 | **Tracciabilità obbligatoria** | `requires_lot` / `requires_serial` per ricetta, verificati sul server: nessuna porta d'ingresso li aggira, barcode compreso |
| 9 | **Blocco inserimento manuale** | `allow_manual_input`: con la ricetta a solo calibro il tastierino non viene disegnato affatto, e il server rifiuta comunque un valore digitato a mano |
| 10 | **Sequenza per l'operatore** | La ricetta si apre sul primo task, non su un elenco. La lista scende a secondo livello e dice quali task sono rimasti incompiuti (`2/3`). «Fine ciclo misura» è visibile da subito e spento finché mancano quote |
| 11 | **Descrizioni formattate** | `**grassetto**` e a capo nelle descrizioni dei task, con marcatura e non HTML: la sanificazione è per costruzione |
| 12 | **Funzionamento senza internet** | Tutte le librerie e i font nell'installazione, versioni congelate e verificate per impronta, Content-Security-Policy a sola origine locale |
| 14 | **Layout stabile** | Una cornice sola per tutte le viste, allineata alla navbar; spazio della barra di scorrimento sempre riservato; schermata di misura su `100dvh`. Vedi [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) |
| 15 | **Statistica separata** | Si registra sempre, si consulta a parte: nessun percorso dell'operatore porta alla statistica (verificato da test) |
**Aperto**: il punto 4 (numero di tentativi prima del capoturno) attende le risposte
del cliente su modalità di autorizzazione e numero di tentativi; il punto 13
(generazione task dalla scheda tecnica con l'AI) è fuori offerta.
---
## Architettura ## Architettura
``` ```
@@ -67,13 +94,22 @@ Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al bac
| Flask | 3.x | Framework web server-side | | Flask | 3.x | Framework web server-side |
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) | | 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) | Reattività leggera lato client | | Alpine.js | 3.15.12 (locale) | Reattività leggera lato client |
| TailwindCSS | 3.x | CSS utility-first | | TailwindCSS | 3.4.19 (build) | CSS utility-first, compilato nell'immagine |
| Plotly.js | CDN | Grafici SPC interattivi | | Plotly.js | 2.32.0 (locale) | Grafici SPC interattivi |
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici | | PDF.js | 3.11.174 (locale) | Visualizzazione disegni PDF, worker incluso |
| html5-qrcode | CDN | Scanner barcode/QR camera | | Fabric.js | 5.3.1 (locale) | Editor annotazioni disegni tecnici |
| Inter + JetBrains Mono | woff2 locali | Font UI e numeri |
| Flask-Babel | ultima stabile | i18n IT/EN | | Flask-Babel | ultima stabile | i18n IT/EN |
**Nessuna libreria arriva dalla rete.** Tutte stanno in
[`src/frontend/flask_app/static/vendor/`](src/frontend/flask_app/static/vendor/VERSIONS.md)
con versione nel nome e impronta SHA-256 verificata da un test: l'installazione a
Tràfilo è su rete di produzione isolata, dove una pagina che aspetta un CDN è una
pagina bianca. Una Content-Security-Policy a sola origine locale (`app.py`) fa sì che
un tag verso l'esterno aggiunto in futuro venga rifiutato alla scrivania, non in
reparto.
### Tooling ### Tooling
| Componente | Ruolo | | Componente | Ruolo |
@@ -91,16 +127,25 @@ Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al bac
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni, build delle immagini con `uv` 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 CON il sottomodulo di VisionSuite (vendor/visionsuite):
git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git # senza --recurse-submodules la cartella resta vuota e la build del worker di
# visione fallisce. Il sottomodulo è su ssh://git.tielogic.xyz:222: serve una
# chiave SSH autorizzata su quel repo, sia sulla macchina di sviluppo sia sulla
# VPS che farà la build.
git clone --recurse-submodules ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
cd TieMeasureFlow cd TieMeasureFlow
# Chi ha già clonato senza il sottomodulo:
# git submodule update --init
# 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, STATION_CODE per ogni tablet # Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
# 3. Avvia i servizi (ambiente di sviluppo) # 3. Valorizza VISION_ENGINE_VERSION PRIMA della build: il worker di visione
docker compose -f docker-compose.dev.yml up -d --build # (Dockerfile.vision) rifiuta di costruirsi senza — vedi "Il worker di
# visione" più sotto per il motivo. Poi avvia i servizi (ambiente di sviluppo).
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
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
@@ -211,7 +256,8 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|---|---| |---|---|
| **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. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) | | **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart | | **Supervisor** (capoturno) | Autorizza ciò che l'operatore non può decidere da solo: una quota fuori tolleranza che deve restare, il fermo linea, la fine produzione. L'autorizzazione resta scritta sulla misura (`supervisor_id`, `authorised_at`) e finisce nel file di statistica |
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart. **L'operatore non raggiunge la statistica da nessun percorso** |
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** | | **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
--- ---
@@ -229,6 +275,7 @@ TieMeasureFlow/
├── docker-compose.yml # Produzione (Traefik, SSL) ├── docker-compose.yml # Produzione (Traefik, SSL)
├── nginx/ # Config Nginx (dev) ├── nginx/ # Config Nginx (dev)
├── uploads/ # Volume Docker file caricati ├── uploads/ # Volume Docker file caricati
├── scripts/ # Script del progetto (seed ricette di collaudo)
├── docs/ # Documentazione (vedi indice docs/README.md) ├── docs/ # Documentazione (vedi indice docs/README.md)
└── src/ └── src/
├── backend/ # FastAPI Backend ├── backend/ # FastAPI Backend
@@ -245,11 +292,14 @@ TieMeasureFlow/
│ │ │ # RecipeTask, RecipeSubtask, Measurement, │ │ │ # RecipeTask, RecipeSubtask, Measurement,
│ │ │ # AccessLog, SystemSetting, │ │ │ # AccessLog, SystemSetting,
│ │ │ # RecipeVersionAudit, Station, │ │ │ # RecipeVersionAudit, Station,
│ │ │ # StationRecipeAssignment │ │ │ # StationRecipeAssignment,
│ │ │ # ProductionRun, ProductionEvent
│ │ └── api/ # Pydantic v2 schemas request/response │ │ └── api/ # Pydantic v2 schemas request/response
│ ├── services/ # recipe_service, measurement_service, │ ├── services/ # recipe_service, measurement_service,
│ │ # spc_service, report_service, │ │ # spc_service, report_service,
│ │ # auth_service, station_service │ │ # auth_service, station_service,
│ │ # production_service,
│ │ # production_export_service
│ ├── migrations/ # Alembic (alembic.ini + env.py) │ ├── migrations/ # Alembic (alembic.ini + env.py)
│ ├── templates/ # Pagina setup (Jinja2) │ ├── templates/ # Pagina setup (Jinja2)
│ └── tests/ # pytest + httpx + aiosqlite │ └── tests/ # pytest + httpx + aiosqlite
@@ -262,9 +312,13 @@ TieMeasureFlow/
├── services/ # APIClient (proxy verso FastAPI con XFF) ├── services/ # APIClient (proxy verso FastAPI con XFF)
├── templates/ # Jinja2 + Alpine.js ├── templates/ # Jinja2 + Alpine.js
├── static/ ├── static/
│ ├── css/ # TailwindCSS compilato │ ├── css/ # TailwindCSS compilato + themes.css
│ └── js/ # numpad, caliper, barcode, csv-export, │ │ # (cornice pagina, temi, scrollbar)
│ # spc-charts, annotation-editor/viewer │ ├── js/ # numpad, caliper, barcode, csv-export,
│ │ # spc-charts, annotation-editor/viewer,
│ │ # production-clock, rich-text
│ └── vendor/ # Alpine, Plotly, PDF.js (+worker), Fabric,
│ # font woff2 — vedi VERSIONS.md
├── translations/ # Flask-Babel .po/.mo IT/EN ├── translations/ # Flask-Babel .po/.mo IT/EN
└── tests/ └── tests/
``` ```
@@ -279,6 +333,7 @@ TieMeasureFlow/
|---|---| |---|---|
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) | | `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 |
| `APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client` | Ricostruisci il client mostrando nel piè di pagina il commit (oltre alla versione di `pyproject.toml`); senza `APP_COMMIT` compare solo la versione |
| `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 |
| `docker compose ps` | Stato di tutti i servizi | | `docker compose ps` | Stato di tutti i servizi |
@@ -341,7 +396,98 @@ uv run pytest --cov src/backend
uv run pytest src/frontend/flask_app/tests/ uv run pytest src/frontend/flask_app/tests/
``` ```
Stato corrente: **183 pass, 0 fail** (vedi `docs/architecture/STATO_PROGETTO.md`). Il modulo di visione (`src/vision/`) è un albero a parte, con un vincolo di
versione diverso dal resto del monorepo: le librerie di VisionSuite da cui
dipende richiedono Python 3.13 o superiore, mentre backend e frontend restano
su Python 3.11. Per questo motivo l'esecuzione di `uv run pytest` con
l'interprete di default mostra i test di `src/vision/tests/` come *skipped*,
non come falliti: il pacchetto viene rilevato ma la sua esecuzione reale
richiede un ambiente Python 3.13 dedicato, creato con il proprio extra `uv`.
Per eseguirli davvero:
```bash
# Prepara l'ambiente Python 3.13 con le dipendenze di visione
uv sync --extra vision --extra dev --python 3.13
# Esegue i test del runner di visione in quell'ambiente
uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests
```
`src/vision_worker/tests/` eredita lo stesso vincolo su Python 3.13, perché
il worker dipende da `src.vision.runner`, ma aggiunge dipendenze proprie -
FastAPI, Uvicorn, Pillow, python-multipart - dichiarate in un extra
separato, `vision-worker`, tenuto distinto da `vision` apposta: un
consumatore che incorpora solo il runner (come un futuro agente di stazione,
che non è un servizio web) non deve trascinarsi dietro un server che non gli
serve. `vision-worker` include comunque `vision`, quindi un solo extra basta
per avere un worker funzionante:
```bash
uv run --python 3.13 --extra vision-worker --extra dev pytest src/vision_worker/tests
```
Per eseguire entrambe le suite insieme, nominando esplicitamente entrambi
gli extra:
```bash
uv run --python 3.13 --extra vision --extra vision-worker --extra dev pytest src/vision/tests src/vision_worker/tests
```
### Il worker di visione (`Dockerfile.vision`)
Il worker gira in un container separato dal server FastAPI principale, così
che l'immagine dell'API resti leggera e un aggiornamento di VisionSuite non
richieda di riavviare il traffico di produzione. Il container non pubblica
porte verso l'esterno: lo raggiunge solo il server, all'indirizzo interno
`http://vision:8100` sulla rete `tmflow-net`.
Ogni misura riporta il commit di VisionSuite che l'ha prodotta
(`engine_version`), perché una stazione che misura con un motore diverso da
quello atteso deve poter essere identificata. Il container, però, non ha
accesso al repository Git del progetto principale — `vendor/visionsuite` è un
submodule, e la copia `.git` che lo collega al repository ospitante non
viene inclusa nel contesto di build — quindi la versione non può essere
scoperta al volo dentro l'immagine. Va invece **stampata al momento del
build**, leggendo il commit dalla macchina che esegue `docker compose build`,
dove il repository Git è presente per intero:
```bash
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
docker compose -f docker-compose.dev.yml build vision
```
La variabile viene passata come build argument (`ARG VISION_ENGINE_VERSION`
in `Dockerfile.vision`) e fissata nell'immagine come variabile d'ambiente, in
modo che il worker la trovi già pronta a ogni avvio senza doverla ricalcolare.
Fuori da un container, in un checkout di sviluppo locale, la stessa funzione
ricade su `git rev-parse` se la variabile non è impostata — comodo per
lavorare sul runner senza Docker. Ma se un'immagine viene costruita senza
passare `VISION_ENGINE_VERSION`, quel ripiego non ha nulla su cui appoggiarsi:
il `.git` del submodule non arriva nel contesto di build, e il worker
risponde con un errore esplicito su `/health` invece di indovinare una
versione o restituire `"unknown"`. La build va quindi sempre lanciata con la
variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando
mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml`
in produzione).
Stato corrente su `V3.0.0`, con `uv run --extra server --extra client --extra
dev pytest` su Python 3.11 e senza l'extra `vision` (un conteggio senza il
suo ambiente non dice nulla, regola che vale anche qui): **622 pass, 4
skip, nessun fallimento** — 410 in `src/backend/tests/`, 212 pass + 2 skip
in `src/frontend/flask_app/tests/` (geometria propria di
`test_layout_shell.py`, non legati alla visione), più 2 skip in
`src/vision/tests/` e `src/vision_worker/tests/` per il vincolo su Python
3.13 descritto sopra. Eseguendo questi ultimi nel loro ambiente dedicato
(comando sotto) i test collegati alla visione passano tutti: **16 pass**.
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
proprietà che sopravvivono solo se qualcuno le controlla:
| File | Cosa impedisce |
|---|---|
| `test_offline.py` | Una libreria caricata dalla rete, un worker PDF.js lasciato sul CDN, una libreria sostituita senza aggiornare l'impronta |
| `test_layout_shell.py` | Una vista che torna a dichiararsi la propria larghezza |
| `test_template_js_syntax.py` | Una traduzione con l'apostrofo dentro una stringa JS a virgolette singole, che spegne Alpine su tutta la pagina |
--- ---
@@ -359,6 +505,7 @@ Copia `.env.example` in `.env` e configura:
| `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`) |
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. | | `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
| `VISION_WORKER_URL` | Indirizzo interno del worker di visione (default: `http://vision:8100`, mai esposto fuori da `tmflow-net`) |
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) | | `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) | | `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) | | `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
@@ -369,6 +516,70 @@ Copia `.env.example` in `.env` e configura:
--- ---
## Camera di stazione (demo fiera, #72)
Per acquisire il fotogramma da una camera locale (per esempio quella del TC bench) senza passare dall'upload, sul PC della stazione gira un piccolo servizio nativo, fuori da Docker, perché la camera GigE non si raggiunge dal container. Si avvia dalla radice del repository con l'ambiente di VisionSuite (`uv sync --all-packages` in `vendor/visionsuite`):
```bash
vendor/visionsuite/.venv/bin/python scripts/camera_service.py [--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
```
Con `--folder` la sorgente `camera:folder` restituisce a rotazione le immagini della cartella, utile per provare senza hardware. Su Linux, perché il container `client` lo raggiunga, si usa `--host 172.17.0.1` (gateway di docker0; il compose di sviluppo mappa `host.docker.internal` su quell'host). Su Windows con Docker Desktop si presume che `host.docker.internal` raggiunga il loopback dell'host, ma va verificato sul PC della demo.
⚠️ Il servizio non ha autenticazione: un `--host` diverso dal loopback va usato solo su una rete isolata.
Nella schermata Stazioni si compilano `camera_url` (per esempio `http://host.docker.internal:8765`) e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Per avere due postazioni sullo stesso PC si abilita `STATION_SWITCH_ENABLED`.
---
## Pacchetto Windows
Per i PC senza Docker (per esempio il PC della stazione in fiera) TieMeasureFlow si distribuisce come programma di installazione per Windows, `TieMeasureFlow-setup-<versione>.exe`. Funziona completamente offline, non richiede Docker né MySQL, e non contiene codice sorgente Python: il codice del prodotto è compilato con Nuitka. Il programma parte solo con una licenza valida, legata al computer.
### Installazione
1. Eseguire `TieMeasureFlow-setup-<versione>.exe` (serve un account amministratore; per una prova senza amministratore si può lanciare il setup con `/CURRENTUSER`). Per un'installazione silenziosa: `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART`.
2. Dal menu Start aprire «Impronta di questo computer» e comunicare l'impronta a Tielogic. L'impronta viene salvata anche in `C:\ProgramData\Tielogic\licenze\impronta-tiemeasureflow.txt`.
3. Copiare il file di licenza ricevuto in `C:\ProgramData\Tielogic\licenze\tiemeasureflow.json`. La licenza non è inclusa nel setup e non va mai inserita nel repository.
4. Avviare «TieMeasureFlow» dal menu Start o dal desktop. Senza una licenza valida (mancante, scaduta, di un altro computer o di un altro prodotto) il programma mostra il motivo e l'impronta ed esce.
### Primo avvio
Il programma gira in un solo processo, senza finestra, e apre il browser. Ascolta solo sul computer locale (`127.0.0.1`); per raggiungere l'interfaccia da altri dispositivi della rete si avvia l'exe con `--host 0.0.0.0` (per esempio da un collegamento personalizzato).
| Componente | Indirizzo |
|---|---|
| Interfaccia web (Flask) | http://127.0.0.1:8780/ |
| Backend API (FastAPI) | http://127.0.0.1:8000/ |
| Worker di visione | http://127.0.0.1:8100/ |
| Camera della stazione | http://127.0.0.1:8765/ (`/camera/sources`) |
Al primo avvio vengono create la cartella dati `C:\ProgramData\Tielogic\TieMeasureFlow` e il file `config.env` con le chiavi segrete generate (`SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY`, `SETUP_PASSWORD`, `DB_PASSWORD`); al riavvio vengono rilette, quindi le sessioni e le password restano valide. Il database è un file SQLite (`tmf.db`); le migrazioni si applicano da sole a ogni avvio.
Se il database non contiene ancora utenti, il browser si apre direttamente sulla pagina di setup `http://127.0.0.1:8000/api/setup`. La pagina chiede la `SETUP_PASSWORD`, che si legge in `config.env`, e permette di inizializzare il database e creare il primo amministratore (vedi «Setup Iniziale»). Terminato il setup si accede all'interfaccia su `http://127.0.0.1:8780/`.
Per le camere della stazione si compila nella schermata Stazioni `camera_url` = `http://127.0.0.1:8765` e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Con `camera:folder` il servizio restituisce a rotazione le immagini che si copiano in `C:\ProgramData\Tielogic\TieMeasureFlow\camera`. Per le camere GigE/GenTL il driver (producer `.cti`) è quello installato dal produttore della camera.
Altre informazioni utili:
- I log sono in `C:\ProgramData\Tielogic\TieMeasureFlow\log\tiemeasureflow.log`.
- Una seconda esecuzione mentre il programma è già attivo apre soltanto il browser.
- Per fermare il programma si usa «Arresta TieMeasureFlow» nel menu Start (chiude il processo).
- La disinstallazione non cancella licenze, database, file caricati e `config.env`: sono dati del cliente.
- I report PDF usano le librerie grafiche incluse nel pacchetto e un browser Chrome o Edge già installato sul computer per i grafici.
### Come si costruisce
Il setup lo produce la CI Gitea (`.gitea/workflows/rilascio-windows.yml`) quando si crea un tag `v*`, su un runner con etichetta `windows`; il file compare come allegato della release. In locale, dalla radice del repository con il sottomodulo `vendor/visionsuite` inizializzato:
```powershell
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione 3.0.0
```
Servono `uv`, Node.js, Visual Studio con il carico C++ e Inno Setup 6. Il risultato è `dist\TieMeasureFlow-setup-3.0.0.exe`. La prima compilazione richiede decine di minuti. I test dell'avvio si lanciano con l'ambiente Python 3.13 del pacchetto: `uv run --python 3.13 --extra server --extra client --extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q`.
---
## Documentazione ## Documentazione
Indice completo: [`docs/README.md`](docs/README.md). Indice completo: [`docs/README.md`](docs/README.md).
@@ -377,6 +588,7 @@ Indice completo: [`docs/README.md`](docs/README.md).
| Documento | Contenuto | | Documento | Contenuto |
|---|---| |---|---|
| [`TieMeasureFlow_modifiche_2026-07-28.md`](TieMeasureFlow_modifiche_2026-07-28.md) | I quindici punti del 28/07: cosa deve cambiare, dove intervenire, decisioni in attesa del cliente (D-1…D-9) |
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali | | [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) | | [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
@@ -388,6 +600,8 @@ Indice completo: [`docs/README.md`](docs/README.md).
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall | | [`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/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) | | [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
| [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava e la regola che lo tiene fermo |
| [`src/frontend/flask_app/static/vendor/VERSIONS.md`](src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte, come aggiornarle |
### Piani dettagliati ### Piani dettagliati
+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.
+52 -3
View File
@@ -14,6 +14,7 @@ services:
--authentication-policy=mysql_native_password --authentication-policy=mysql_native_password
--character-set-server=utf8mb4 --character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci --collation-server=utf8mb4_unicode_ci
--default-time-zone=+00:00
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s interval: 10s
@@ -31,13 +32,42 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
APP_ENV: dev
DB_HOST: mysql DB_HOST: mysql
UPLOAD_DIR: uploads UPLOAD_DIR: uploads
VISION_WORKER_URL: http://vision:8100
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1}
volumes: volumes:
- upload_data:/app/uploads - upload_data:/app/uploads
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- tmflow-net
vision:
build:
context: .
dockerfile: Dockerfile.vision
args:
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
container_name: tmflow-vision
restart: unless-stopped
volumes:
- upload_data:/app/uploads:ro
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks: networks:
- tmflow-net - tmflow-net
@@ -45,14 +75,31 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.frontend dockerfile: Dockerfile.frontend
args:
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
APP_COMMIT: ${APP_COMMIT:-}
container_name: tmflow-client container_name: tmflow-client
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment: environment:
API_SERVER_URL: http://server:8000 API_SERVER_URL: http://server:8000
# Nginx qui non termina SSL (vedi nginx/nginx.conf): un'installazione
# on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure,
# altrimenti il browser non lo rimanda mai indietro.
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
# #72: da container si raggiunge il servizio camera (vs-gui) sull'host Linux
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on: depends_on:
- server server:
condition: service_healthy
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/auth/login', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks: networks:
- tmflow-net - tmflow-net
@@ -69,8 +116,10 @@ services:
# - ./certbot/conf:/etc/letsencrypt:ro # - ./certbot/conf:/etc/letsencrypt:ro
# - ./certbot/www:/var/www/certbot:ro # - ./certbot/www:/var/www/certbot:ro
depends_on: depends_on:
- server server:
- client condition: service_healthy
client:
condition: service_healthy
networks: networks:
- tmflow-net - tmflow-net
+55 -8
View File
@@ -4,16 +4,17 @@ services:
container_name: tmflow-mysql container_name: tmflow-mysql
restart: unless-stopped restart: unless-stopped
environment: environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root_password_change_me} MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?manca DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME:-tiemeasureflow} MYSQL_DATABASE: ${DB_NAME:-tiemeasureflow}
MYSQL_USER: ${DB_USER:-tmflow} MYSQL_USER: ${DB_USER:-tmflow}
MYSQL_PASSWORD: ${DB_PASSWORD:-change_me_in_production} MYSQL_PASSWORD: ${DB_PASSWORD:?manca DB_PASSWORD}
volumes: volumes:
- mysql_data:/var/lib/mysql - mysql_data:/var/lib/mysql
command: > command: >
--authentication-policy=mysql_native_password --authentication-policy=mysql_native_password
--character-set-server=utf8mb4 --character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci --collation-server=utf8mb4_unicode_ci
--default-time-zone=+00:00
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s interval: 10s
@@ -31,52 +32,98 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
APP_ENV: production
DB_HOST: mysql DB_HOST: mysql
UPLOAD_DIR: uploads UPLOAD_DIR: uploads
VISION_WORKER_URL: http://vision:8100
SERVER_SECRET_KEY: ${SERVER_SECRET_KEY:?manca SERVER_SECRET_KEY}
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1}
volumes: volumes:
- upload_data:/app/uploads - upload_data:/app/uploads
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.tmflow-api.rule=Host(`tieflow.tielogic.xyz`) && (PathPrefix(`/api/`) || PathPrefix(`/uploads/`))" - "traefik.http.routers.tmflow-api.rule=Host(`${APP_DOMAIN:?manca APP_DOMAIN}`) && PathPrefix(`/api/`)"
- "traefik.http.routers.tmflow-api.entrypoints=web,websecure" - "traefik.http.routers.tmflow-api.entrypoints=web,websecure"
- "traefik.http.routers.tmflow-api.tls=true" - "traefik.http.routers.tmflow-api.tls=true"
- "traefik.http.routers.tmflow-api.tls.certresolver=mytlschallenge" - "traefik.http.routers.tmflow-api.tls.certresolver=mytlschallenge"
- "traefik.http.routers.tmflow-api.priority=100" - "traefik.http.routers.tmflow-api.priority=100"
- "traefik.http.services.tmflow-api.loadbalancer.server.port=8000" - "traefik.http.services.tmflow-api.loadbalancer.server.port=8000"
- "traefik.http.routers.tmflow-api.middlewares=tmflow-headers"
networks: networks:
- tmflow-net - tmflow-net
- traefik-net - traefik-net
vision:
build:
context: .
dockerfile: Dockerfile.vision
args:
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
container_name: tmflow-vision
restart: unless-stopped
environment:
# #46: radice delle cartelle ricetta (volume uploads, sola lettura)
VISION_RECIPES_ROOT: /app/uploads
volumes:
- upload_data:/app/uploads:ro
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- tmflow-net
client: client:
build: build:
context: . context: .
dockerfile: Dockerfile.frontend dockerfile: Dockerfile.frontend
args:
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
APP_COMMIT: ${APP_COMMIT:-}
container_name: tmflow-client container_name: tmflow-client
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment: environment:
API_SERVER_URL: http://server:8000 API_SERVER_URL: http://server:8000
CLIENT_SECRET_KEY: ${CLIENT_SECRET_KEY:?manca CLIENT_SECRET_KEY}
depends_on: depends_on:
- server server:
condition: service_healthy
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/auth/login', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.tmflow-web.rule=Host(`tieflow.tielogic.xyz`)" - "traefik.http.routers.tmflow-web.rule=Host(`${APP_DOMAIN:?manca APP_DOMAIN}`)"
- "traefik.http.routers.tmflow-web.entrypoints=web,websecure" - "traefik.http.routers.tmflow-web.entrypoints=web,websecure"
- "traefik.http.routers.tmflow-web.tls=true" - "traefik.http.routers.tmflow-web.tls=true"
- "traefik.http.routers.tmflow-web.tls.certresolver=mytlschallenge" - "traefik.http.routers.tmflow-web.tls.certresolver=mytlschallenge"
- "traefik.http.routers.tmflow-web.priority=10" - "traefik.http.routers.tmflow-web.priority=10"
- "traefik.http.services.tmflow-web.loadbalancer.server.port=5000" - "traefik.http.services.tmflow-web.loadbalancer.server.port=5000"
- "traefik.http.middlewares.tmflow-headers.headers.SSLRedirect=true" # Traefik v3: SSLRedirect e browserXSSFilter non ci sono più (SSL redirect
- "traefik.http.middlewares.tmflow-headers.headers.STSSeconds=315360000" # si fa a livello di entrypoint, l'XSS Auditor del browser non esiste più).
- "traefik.http.middlewares.tmflow-headers.headers.browserXSSFilter=true" - "traefik.http.middlewares.tmflow-headers.headers.stsSeconds=315360000"
- "traefik.http.middlewares.tmflow-headers.headers.contentTypeNosniff=true" - "traefik.http.middlewares.tmflow-headers.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.tmflow-headers.headers.forceSTSHeader=true" - "traefik.http.middlewares.tmflow-headers.headers.forceSTSHeader=true"
- "traefik.http.middlewares.tmflow-headers.headers.STSIncludeSubdomains=true" - "traefik.http.middlewares.tmflow-headers.headers.STSIncludeSubdomains=true"
- "traefik.http.middlewares.tmflow-headers.headers.STSPreload=true" - "traefik.http.middlewares.tmflow-headers.headers.STSPreload=true"
- "traefik.http.middlewares.tmflow-headers.headers.customFrameOptionsValue=SAMEORIGIN"
- "traefik.http.routers.tmflow-web.middlewares=tmflow-headers" - "traefik.http.routers.tmflow-web.middlewares=tmflow-headers"
networks: networks:
- tmflow-net - tmflow-net
+191
View File
@@ -0,0 +1,191 @@
# Scaletta di collaudo — V3.0.0
Tredici dei quindici punti del 28/07 sono in esercizio e **nessuno li ha ancora
percorsi**. Questa è la scaletta per farlo: ordinata in modo che ogni prova prepari
la successiva, e scritta perché si possa spuntare riga per riga.
Per ogni riga: cosa fare, e **come si vede che è giusto**. Se qualcosa non torna,
annotare il testo esatto dell'eventuale errore in console — non «dava errore».
Totale stimato: **circa 90 minuti**, di cui 30 sul solo punto 3 (il timer, che si
misura aspettando).
---
## 0 · Preparazione (5 min)
| | Cosa | Verifica |
|---|---|---|
| 0.1 | Aprire il sito e fare **ricarica forzata** (`Ctrl+Shift+R`) | CSS e JS sono in cache per 7 giorni: senza questo si collauda la versione di ieri |
| 0.2 | Aprire la **console del browser** (`F12`) e lasciarla aperta per tutto il collaudo | Molte prove si leggono lì, non a schermo |
| 0.3 | Verificare che esistano le ricette `COLLAUDO-A` e `COLLAUDO-B` | Sono in `/measure/select`. Se una sessione precedente le ha sporcate: `docker compose exec server uv run python /tmp/seed_collaudo.py --replace` (cancella anche le misure fatte su di esse) |
**Credenziali del capoturno:** `capoturno` / `Collaudo2026!` — da cambiare prima di
qualunque uso reale.
**Le ricette:**
- **COLLAUDO-A** — lotto obbligatorio, digitazione ammessa, intervallo **2 minuti**.
Cinque task: nota → misura (2 quote) → nota → misura (1 quota) → nota.
- **COLLAUDO-B** — lotto **e** seriale obbligatori, **solo calibro**, una quota.
**Le quote e i valori da usare** (tutte con nominale al centro):
| Quota | Task | Nominale | Conforme | Attenzione | Fuori tolleranza |
|---|---|---|---|---|---|
| 1 · Diametro esterno | A, misura 1 | 10.00 | `10.00` | `10.30` | `12.00` |
| 2 · Spessore parete | A, misura 1 | 25.00 | `25.00` | `25.30` | `27.00` |
| 3 · Lunghezza totale | A, misura 2 | 100.00 | `100.00` | `100.70` | `105.00` |
---
## 1 · Layout — punto 14 (10 min) · **da monitor da scrivania**
Va fatto **prima** e **non dal tablet**: in verticale su un tablet quasi tutte le
larghezze coincidono e il difetto non si vede. Serve uno schermo largo.
| | Cosa | Verifica |
|---|---|---|
| 1.1 | Percorrere Ricette → apri una ricetta → Task → Disegno → Anteprima, e tornare indietro | Il blocco di contenuto **non cambia larghezza** fra una schermata e l'altra |
| 1.2 | Su ognuna, guardare il bordo sinistro del contenuto e quello della barra in alto | Sono **allineati**: il contenuto sta dentro la stessa cornice della navbar |
| 1.3 | Passare da una pagina lunga (che scorre) a una corta (che non scorre) | Niente **salta di lato** di qualche pixel: lo spazio della barra di scorrimento è riservato sempre |
| 1.4 | Entrare in un task di misura e uscirne | Stesso controllo: nessuno spostamento orizzontale |
| 1.5 | Admin → Utenti, Stazioni, Impostazioni; poi il proprio Profilo | Utenti e Stazioni sono larghe, Impostazioni e Profilo strette — ma con **gli stessi margini esterni** |
| 1.6 | **Dal tablet**, entrare in un task di misura | Il piede con «Fine ciclo misura» e il tastierino è visibile **senza scorrere**, anche quando la barra dell'indirizzo compare e sparisce |
---
## 2 · Niente arriva dalla rete — punto 12 (15 min)
Da fare **adesso**, perché la Content-Security-Policy è appena entrata in vigore: se
una libreria avesse bisogno di un permesso non concesso, il sintomo compare ora.
> **Attenzione al metodo.** Il sistema è oggi su una VPS pubblica: staccare la rete
> significherebbe perdere anche il server, quindi non prova niente. La prova
> equivalente e più diretta è guardare **dove vanno le richieste**. Il giro a rete
> davvero staccata si fa in reparto, sull'installazione on-premise.
| | Cosa | Verifica |
|---|---|---|
| 2.1 | Console → scheda **Rete**, spuntare «Disabilita cache», ricaricare | Ordinando per dominio compare **un solo dominio**: `tieflow.tielogic.xyz`. Nessun `cdn`, `jsdelivr`, `cloudflare`, `googleapis`, `plot.ly` |
| 2.2 | Ripetere su: login, selezione ricetta, esecuzione task, editor annotazioni (`/maker`), dashboard statistiche | Sempre un dominio solo. La dashboard è quella che carica Plotly, l'editor quella che carica Fabric |
| 2.3 | Aprire un task che ha un **disegno PDF** allegato | Il disegno si vede. È la prova del *worker* di PDF.js: se cercasse la rete, tutto sembrerebbe a posto fino a questo momento |
| 2.4 | Con la console aperta su ogni pagina del giro | **Zero** messaggi che iniziano con `Refused to load…` o `…violates the following Content Security Policy directive`. Se ce n'è uno, copiarlo per intero: dice quale permesso manca |
| 2.5 | Generare un **report PDF** dalle statistiche | Il PDF esce. È generato sul server, non nel browser, ma vale la pena vederlo funzionare nel giro completo |
---
## 3 · Le regole della ricetta — punti 8 e 9 (10 min) · **COLLAUDO-B**
| | Cosa | Verifica |
|---|---|---|
| 3.1 | `/measure/select`, **senza** compilare lotto e seriale | Sulla scheda di COLLAUDO-B il pulsante è spento e dice **«Compila prima: lotto, seriale»** |
| 3.2 | Compilare **solo** il lotto | Il pulsante resta spento e ora chiede solo il **seriale** |
| 3.3 | Provare ad andare a mano su `/measure/start/<id>` di COLLAUDO-B senza seriale | Rimanda alla selezione con il messaggio «Compila prima:». La regola non si aggira dall'indirizzo |
| 3.4 | Compilare entrambi e avviare | Entra nel task |
| 3.5 | Guardare il tastierino | I tasti numerici **non ci sono affatto** — non sono grigi, non ci sono. Restano «cancella» e «conferma» |
| 3.6 | Con un **calibro USB** collegato, prendere una lettura | Il valore entra e si salva |
| 3.7 | *(facoltativo, per chi vuole la prova dura)* Provare a salvare un valore digitato aggirando lo schermo | Il server risponde **422**: la regola vive lì, non nella pagina |
---
## 4 · Il primo ciclo e l'avvio produzione — punti 10 e 11 (10 min) · **COLLAUDO-A**
> Da qui in avanti si lavora su COLLAUDO-A. **Il timer non parte subito**: prima si
> fa un ciclo completo, poi compare «Avvio Produzione». È voluto — il primo pezzo è
> l'attrezzaggio, non produzione.
| | Cosa | Verifica |
|---|---|---|
| 4.1 | Selezione ricetta → COLLAUDO-A → lotto `LOT-TEST-01` → **AVVIA IN SEQUENZA** | Si apre **il primo task** (la nota «Preparazione del pezzo»), **non** l'elenco dei task |
| 4.2 | Leggere la nota | «a sinistra» è in **grassetto**, le righe vanno a capo e la riga vuota si vede (punto 11) |
| 4.3 | Guardare i due pulsanti in alto a destra | Sono **«Lista task»** e **«Completato»**. Su una nota «Completato» è attivo |
| 4.4 | Premere «Completato» | Passa al task 2, «Misura 1 — diametro e spessore» |
| 4.5 | Guardare il pulsante in basso a destra | **«Mancano 2 quote»**, grigio e non cliccabile |
| 4.6 | Misurare la quota 1 con `10.00` e confermare | Verde (conforme). Il pulsante in basso ora dice **«Manca 1 quota»** |
| 4.7 | **Prova chiave del punto 10**: premere «Lista task» adesso, a metà | Nel'elenco il task «Misura 1» porta **«Incompiuto 1/2»** con il bordo ambra. Gli altri non dicono niente |
| 4.8 | Premere **«Visualizza singolo TASK»** su quel task | Ci si torna dentro, con la quota già presa |
| 4.9 | Misurare la quota 2 con `25.00` | Il pulsante in basso diventa blu: **«Fine ciclo misura»** |
| 4.10 | Premerlo | Compare il verde **«Task successivo»** (non un secondo «Completato») |
| 4.11 | Guardare sotto il piede della pagina | Compare il pulsante verde **«Avvio Produzione»** |
| 4.12 | Premere «Avvio Produzione» | Parte il conto alla rovescia da **2 minuti**. Da qui in avanti compaiono «Fermo linea» e «Fine produzione» |
---
## 5 · Il fuori tolleranza — punto 5 (15 min)
La parte più importante del collaudo: fino a ieri questo gate era una modale che si
poteva chiudere.
| | Cosa | Verifica |
|---|---|---|
| 5.1 | Tornare su «Misura 1». Cliccare la **quota 1** in alto per rimisurarla, e inserire `12.00` | Si salva ed è **rossa**. La misura sbagliata resta a registro: è un dato, non un errore da nascondere |
| 5.2 | Provare a passare alla **quota 2** e salvare un valore | **Rifiutato.** Compare l'avviso che serve l'autorizzazione |
| 5.3 | Guardare «Completato» in alto | È **spento**, e passandoci sopra dice «Serve l'autorizzazione del capoturno» |
| 5.4 | **Ricaricare la pagina** (`F5`) | Il blocco **è ancora lì**. Ricaricare non è una via d'uscita |
| 5.5 | **La via d'uscita senza capoturno**: cliccare di nuovo la quota 1 e rimisurarla con `10.00` | Il blocco **si scioglie**. È voluto: il calibro scivola, il pezzo si riposiziona, e chiamare il capoturno per questo sarebbe assurdo |
| 5.6 | Rifarlo: quota 1 con `12.00`, e stavolta **autorizzare** con `capoturno` / `Collaudo2026!` | Il blocco si scioglie e la misura resta segnata come autorizzata |
| 5.7 | Provare ad autorizzare con una password sbagliata | Rifiutato, e il blocco **resta** |
| 5.8 | Provare ad autorizzare con un utente **senza** ruolo Supervisor (es. l'operatore stesso) | Rifiutato: non basta essere collegati, serve il ruolo |
| 5.9 | Rimettere `10.00` sulla quota 1 e `25.00` sulla 2, chiudere il ciclo | Si prosegue normalmente |
---
## 6 · Il ciclo di misura — punto 3 (30 min, quasi tutti di attesa)
| | Cosa | Verifica |
|---|---|---|
| 6.1 | Con il conto alla rovescia in corso, **cambiare task** | Il tempo residuo **prosegue**, non riparte da 2 minuti |
| 6.2 | **Ricaricare la pagina** (`F5`) | Idem: il tempo è lo stesso. Vive sul server, non nella pagina |
| 6.3 | Fermarsi su una **nota** e lasciare scadere l'intervallo | A ~5 secondi dalla scadenza compare l'avviso e **suona il cicalino** |
| 6.4 | Non toccare niente | Alla scadenza si viene portati **al primo task di misura** della ricetta, non a quello dove si era |
| 6.5 | Fare un ciclo completo: misura 1 (2 quote) → nota → **misura 2** (quota 3, `100.00`) → «Fine ciclo misura» | Chiudendo il ciclo **sull'ultimo task di misura** l'intervallo **riparte da 2 minuti** |
| 6.6 | Ripetere ma chiudendo il ciclo sulla **prima** misura | L'intervallo **non** riparte: il ciclo si chiude solo sull'ultimo task di misura |
| 6.7 | **La prova più delicata**: stare **dentro** un task di misura, con una quota già presa e una da prendere, e lasciare scadere l'intervallo | **Non** si viene spostati. Il conteggio diventa **rosso** e prosegue **oltre lo zero**, mostrando il ritardo. Chi sta misurando va lasciato finire |
| 6.8 | Usare **«Rimisura»** dopo aver completato le quote | Registra una seconda lettura dello stesso pezzo **senza** far ripartire l'intervallo. Girare il pezzo e rimisurare non è un pezzo nuovo |
---
## 7 · Fermo linea e fine produzione — punto 6 (10 min)
| | Cosa | Verifica |
|---|---|---|
| 7.1 | Dentro un task di misura, premere **«Fermo linea»** → credenziali capoturno | Il conto alla rovescia **si ferma** |
| 7.2 | Ricaricare la pagina | È ancora fermo: lo stato è sul server |
| 7.3 | Premere **«Ripresa»** → credenziali | Riparte |
| 7.4 | Premere **«Fine produzione»** → credenziali | La produzione si chiude |
| 7.5 | Sul server: `docker compose exec server ls uploads/statistics/` | C'è un file `production_<id>_<data>.csv` |
| 7.6 | Aprire il file | Contiene **tutte** le misure della produzione, compresi i `12.00`. Sulla riga autorizzata le colonne `authorised_by` e `authorised_at` sono **valorizzate**: il file mostra il guasto **e** la decisione |
| 7.7 | Aprire il file in Excel | Le accentate si leggono, il separatore è quello configurato |
---
## 8 · La statistica resta separata — punto 15 (5 min)
| | Cosa | Verifica |
|---|---|---|
| 8.1 | Collegarsi con un utente che ha **solo** il ruolo MeasurementTec | In barra **non c'è** la voce Statistiche |
| 8.2 | Percorrere tutto il flusso operatore guardando ogni schermata | Nessun collegamento porta alla statistica, in nessun punto |
| 8.3 | Andare a mano su `/statistics/dashboard` | **403**: il ruolo non basta |
| 8.4 | Verificare che le misure ci siano comunque | Con un utente Metrologist, le misure appena fatte compaiono in dashboard. Si registra sempre, si consulta a parte |
---
## Cosa annotare
Per ogni riga che non torna:
1. **Numero della riga** di questa scaletta.
2. **Cosa è successo** invece di quello che c'è scritto.
3. Se c'è un errore in console, il **testo esatto**, per intero.
4. Se è un problema di aspetto, uno **screenshot** e su **quale schermo** (tablet o
monitor, e in che orientamento) — per il punto 14 è l'informazione che manca.
## Cosa questa scaletta non copre
- Il **punto 4** (numero di tentativi prima del capoturno) non è implementato: è
fermo sulle risposte del cliente (D-6, D-7, D-9).
- Il **cicalino** è il suono del browser. Se serva una colonnina luminosa è D-5.
- Il giro a **rete davvero staccata** si fa in reparto, sull'installazione
on-premise: qui si prova l'equivalente (nessuna richiesta esce dal dominio).
- Il **carico** con venti tablet insieme non è mai stato misurato.
+180 -281
View File
@@ -142,6 +142,19 @@ SSL_KEYFILE=
| `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) |
Introdotte dalla revisione del 21/09/2026:
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `APP_ENV` | string | dev | Ambiente di esecuzione. `dev`/`test` saltano il controllo sui segreti di default; qualsiasi altro valore lo attiva. `docker-compose.yml` lo imposta a `production`, `docker-compose.dev.yml` a `dev` |
| `APP_DOMAIN` | string | *(nessuno, obbligatoria)* | Dominio pubblico dell'installazione, usato nelle rule Traefik di `docker-compose.yml` (solo produzione) |
| `TRUSTED_PROXY_COUNT` | int | 1 | Quanti hop di reverse proxy fidati stanno davanti al server, per calcolare l'IP reale del tablet |
| `WEB_CONCURRENCY` | int | 1 | Numero di worker Uvicorn del server; il `Dockerfile` ne avvia 4 di default indipendentemente da questa variabile |
| `SESSION_COOKIE_SECURE` | bool | true | Cookie di sessione Flask con flag Secure (richiede HTTPS). `false` per le installazioni on-premise su LAN in HTTP semplice (`docker-compose.dev.yml`) |
| `VISION_RUN_TIMEOUT_S` | int | 100 | Timeout, in secondi, di un'esecuzione del worker di visione |
| `VISION_MAX_CONCURRENCY` | int | 2 | Esecuzioni di visione in parallelo accettate dal worker |
| `VISION_ENGINE_VERSION` | string | *(nessuno, obbligatoria alla build)* | Commit di `vendor/visionsuite` stampato nell'immagine `vision` al momento della build — vedi [Docker Deployment](#docker-deployment) |
--- ---
## Database Setup ## Database Setup
@@ -169,63 +182,33 @@ EXIT;
### 2. Run Database Migrations ### 2. Run Database Migrations
```bash The monorepo is gestito con `uv`, non con `requirements.txt` per sottocartella: si esegue dalla radice del repository, con il flag `-c` perché `alembic.ini` vive in `src/backend/migrations/`.
cd server
pip install -r requirements.txt
alembic upgrade head
```
This creates all required tables:
- `users` - System users
- `recipes` - Measurement recipes
- `recipe_versions` - Immutable recipe versions
- `recipe_tasks` - Tasks within recipes
- `recipe_subtasks` - Subtasks within tasks (individual measurements)
- `measurements` - Recorded measurements
- `access_logs` - API access audit trail
- `system_settings` - Configuration key-value pairs
- `recipe_version_audit` - Recipe change history
### 3. Create Initial Admin User (Optional)
Use the Flask client to create the first admin user, or run:
```bash ```bash
cd server uv run alembic -c src/backend/migrations/alembic.ini upgrade head
python -c "
from database import init_db, SessionLocal
from services.auth_service import create_user
import asyncio
async def init():
await init_db()
async with SessionLocal() as db:
await create_user(
db,
username='admin',
password='change_me_first_login',
display_name='Admin',
email='admin@example.com',
roles=['Maker', 'MeasurementTec', 'Metrologist'],
is_admin=True
)
await db.commit()
print('Admin user created: admin / change_me_first_login')
asyncio.run(init())
"
``` ```
Questo è lo stesso comando che il container del server esegue da solo a ogni avvio (`CMD` del `Dockerfile`, prima di `uvicorn`): un deployment Docker non richiede questo passo a mano. La prima migrazione crea lo schema base anche su un database completamente vuoto — non serve più creare le tabelle a parte prima di lanciare `upgrade head`.
### 3. Create Initial Admin User
Non c'è più uno script Python da lanciare a mano: il primo utente amministratore si crea dalla pagina `/api/setup` (protetta da `SETUP_PASSWORD`, vedi sopra), che inizializza il database, crea l'admin con le credenziali fornite nel form e, opzionalmente, carica i dati demo — vedi [`README.md`](../README.md#setup-iniziale).
Una volta creato il primo amministratore, l'endpoint di setup si disattiva da solo: le richieste successive di inizializzazione o creazione utenti ricevono `403 Forbidden`, anche con `SETUP_PASSWORD` corretta. Per rieseguirlo — ad esempio per riconfigurare una stazione da zero — si imposta `SETUP_ALLOW_AFTER_INIT=true` nell'ambiente del server, si esegue l'operazione, e si toglie la variabile: non è pensata per restare attiva in modo permanente.
--- ---
## Server Deployment ## Server Deployment
Docker Compose (sezione successiva) è il metodo raccomandato e quello che gira
in produzione. Questa sezione descrive l'avvio **senza Docker**, utile per
sviluppare sul runner senza ricostruire un'immagine a ogni modifica.
### Development Server ### Development Server
```bash ```bash
cd server uv sync --extra server --extra dev
pip install -r requirements.txt uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
uvicorn main:app --reload --host 0.0.0.0 --port 8000
``` ```
Runs at `http://0.0.0.0:8000` Runs at `http://0.0.0.0:8000`
@@ -234,40 +217,15 @@ API Documentation:
- Swagger UI: `http://localhost:8000/docs` - Swagger UI: `http://localhost:8000/docs`
- ReDoc: `http://localhost:8000/redoc` - ReDoc: `http://localhost:8000/redoc`
### Production Server (Gunicorn + Uvicorn) ### Production, senza Docker
Install Gunicorn: Lo stesso comando che gira dentro il container (`Dockerfile`), lanciato dalla
radice del repository con l'interprete e il venv pinnati da `uv`:
```bash ```bash
pip install gunicorn uv run uvicorn src.backend.main:app \
``` --host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY:-4} \
--proxy-headers --forwarded-allow-ips='*'
Start server:
```bash
cd server
gunicorn main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--access-logfile /var/log/tiemeasureflow/access.log \
--error-logfile /var/log/tiemeasureflow/error.log \
--log-level info
```
### Production Server (Waitress - Windows)
Install Waitress:
```bash
pip install waitress
```
Start server:
```bash
cd server
waitress-serve --host=0.0.0.0 --port=8000 main:app
``` ```
### systemd Service (Linux) ### systemd Service (Linux)
@@ -280,14 +238,14 @@ Description=TieMeasureFlow API Server
After=network.target mysql.service After=network.target mysql.service
[Service] [Service]
Type=notify Type=simple
User=tiemeasureflow User=tiemeasureflow
WorkingDirectory=/opt/tiemeasureflow/server WorkingDirectory=/opt/tiemeasureflow
Environment="PATH=/opt/tiemeasureflow/venv/bin" EnvironmentFile=/opt/tiemeasureflow/.env
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn main:app \ Environment="WEB_CONCURRENCY=4"
--workers 4 \ ExecStart=/opt/tiemeasureflow/.venv/bin/uv run uvicorn src.backend.main:app \
--worker-class uvicorn.workers.UvicornWorker \ --host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY} \
--bind 0.0.0.0:8000 --proxy-headers --forwarded-allow-ips='*'
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
@@ -315,12 +273,14 @@ sudo journalctl -u tiemeasureflow-api -f
## Client Deployment ## Client Deployment
Anche qui, Docker Compose è il metodo raccomandato; questa sezione è per lo
sviluppo o un'installazione manuale senza container.
### Development Server ### Development Server
```bash ```bash
cd client uv sync --extra client --extra dev
pip install -r requirements.txt cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
flask run --host 0.0.0.0 --port 5000
``` ```
Runs at `http://0.0.0.0:5000` Runs at `http://0.0.0.0:5000`
@@ -328,16 +288,20 @@ Runs at `http://0.0.0.0:5000`
### Compile Tailwind CSS (Optional) ### Compile Tailwind CSS (Optional)
```bash ```bash
cd client 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
``` ```
### Production Server (Gunicorn) ### Production, senza Docker
Lo stesso comando del `Dockerfile.frontend` (gunicorn richiede la cwd dentro
`flask_app` per risolvere `app:create_app()`):
```bash ```bash
pip install gunicorn cd src/frontend/flask_app && uv run --project ../../.. gunicorn \
cd client --workers 5 --threads 4 --worker-class gthread --timeout 60 \
gunicorn --workers 4 --bind 0.0.0.0:5000 app:app --bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
"app:create_app()"
``` ```
### systemd Service (Linux) ### systemd Service (Linux)
@@ -350,15 +314,14 @@ Description=TieMeasureFlow Web Client
After=network.target tiemeasureflow-api.service After=network.target tiemeasureflow-api.service
[Service] [Service]
Type=notify Type=simple
User=tiemeasureflow User=tiemeasureflow
WorkingDirectory=/opt/tiemeasureflow/client WorkingDirectory=/opt/tiemeasureflow/src/frontend/flask_app
Environment="PATH=/opt/tiemeasureflow/venv/bin" EnvironmentFile=/opt/tiemeasureflow/.env
Environment="FLASK_ENV=production" ExecStart=/opt/tiemeasureflow/.venv/bin/uv run --project /opt/tiemeasureflow gunicorn \
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn \ --workers 5 --threads 4 --worker-class gthread --timeout 60 \
--workers 4 \ --bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
--bind 0.0.0.0:5000 \ "app:create_app()"
app:app
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
@@ -373,8 +336,9 @@ WantedBy=multi-user.target
### Security Checklist ### Security Checklist
- [ ] Change all default passwords in `.env` - [ ] Change all default passwords in `.env` — con `docker-compose.yml`, `DB_ROOT_PASSWORD`, `DB_PASSWORD`, `SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY` non hanno più un default: `docker compose config` si rifiuta di partire se mancano (introdotto dalla revisione del 21/09/2026)
- [ ] Generate random `SERVER_SECRET_KEY` (32+ characters) - [ ] Generate random `SERVER_SECRET_KEY` (32+ characters)
- [ ] Set `APP_DOMAIN` to the real public domain (used by the Traefik router rules)
- [ ] Set CORS origins to actual client domains - [ ] Set CORS origins to actual client domains
- [ ] Enable SSL/HTTPS (see SSL/TLS section) - [ ] Enable SSL/HTTPS (see SSL/TLS section)
- [ ] Configure firewall rules - [ ] Configure firewall rules
@@ -386,200 +350,140 @@ WantedBy=multi-user.target
### Recommended Architecture ### Recommended Architecture
Il progetto usa due reverse proxy diversi a seconda dell'installazione, non uno
generico da configurare a mano:
- **On-premise, rete LAN, HTTP semplice** (`docker-compose.dev.yml`): Nginx,
configurazione già pronta in [`nginx/nginx.conf`](../nginx/nginx.conf). È il
caso di una stazione di misura sul reparto, senza certificato TLS.
- **Produzione, dominio pubblico** (`docker-compose.yml`): Traefik, con
certificati Let's Encrypt automatici. La configurazione sta nelle label
Docker del servizio, non in un file a parte — vedi la sezione
[Docker Deployment](#docker-deployment) sotto.
``` ```
┌─────────────────┐ ┌──────────────────────────┐
│ Nginx Proxy │ (Port 80/443) On-premise (LAN) │ Nginx (:80) │
└────────┬────────┘ └─────────────┬────────────┘
│ │
┌────┴────┐ Produzione (dominio pubblico) → Traefik (:80/:443, TLS Let's Encrypt)
│ │ │
┌───▼──┐ ┌───▼──┐ ┌───────────┴───────────┐
│API │ │Web │ │ │
│:8000 │ │:5000 │ ┌─────▼─────┐ ┌──────▼─────┐
└──────┘ └──────┘ │ server │ │ client │
│ │ │ :8000 │ │ :5000 │
└────┬─────┘ └─────┬──────┘ └────────────┘
│ │
┌────▼────┐ ┌─────▼──────┐ ┌─────────────┐
│ MySQL │ │ vision │ │ MySQL 8.0 │
│ :3306 │ │ :8100 │ │ :3306 │
└─────────┘ │ (interno) │ └─────────────┘
└─────────────┘
``` ```
### Nginx Configuration Il worker di visione (`vision`) non pubblica porte verso l'esterno: lo
raggiunge solo il `server`, sulla rete Docker interna `tmflow-net`.
Create `/etc/nginx/sites-available/tiemeasureflow`:
```nginx
upstream tiemeasureflow_api {
server 127.0.0.1:8000;
}
upstream tiemeasureflow_web {
server 127.0.0.1:5000;
}
server {
listen 80;
server_name yourdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/ssl/certs/yourdomain.com.crt;
ssl_certificate_key /etc/ssl/private/yourdomain.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 50M;
# API
location /api/ {
proxy_pass http://tiemeasureflow_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Web Client
location / {
proxy_pass http://tiemeasureflow_web;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Static files (optional caching)
location /static/ {
proxy_pass http://tiemeasureflow_web;
expires 1d;
add_header Cache-Control "public, immutable";
}
}
```
Enable and test:
```bash
sudo ln -s /etc/nginx/sites-available/tiemeasureflow /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
```
--- ---
## Docker Deployment ## Docker Deployment
### Docker Compose (Complete Stack) Docker Compose è il metodo raccomandato ed è quello usato in produzione. Il
progetto ha **tre Dockerfile alla radice** del repository — non uno a
sottocartella:
The project includes a `docker-compose.yml` for easy deployment: | File | Servizio | Base |
|---|---|---|
| `Dockerfile` | `server` (FastAPI) | `python:3.11.16-slim` |
| `Dockerfile.frontend` | `client` (Flask) | `python:3.11.16-slim`, TailwindCSS compilato in uno stage `node:20.20.2-bookworm-slim` |
| `Dockerfile.vision` | `vision` (worker di visione) | `python:3.13.15-slim` |
E due file compose:
- **`docker-compose.dev.yml`** — sviluppo/on-premise, con Nginx (vedi
[Recommended Architecture](#recommended-architecture) sopra). 4 servizi:
`mysql`, `server`, `vision`, `client`, `nginx`.
- **`docker-compose.yml`** — produzione, con Traefik e TLS automatico. 4
servizi: `mysql`, `server`, `vision`, `client`. Richiede la rete Docker
esterna `traefik` (vedi `networks.traefik-net` nel file) e le variabili
`APP_DOMAIN`, `DB_ROOT_PASSWORD`, `DB_PASSWORD`, `SERVER_SECRET_KEY`,
`CLIENT_SECRET_KEY` valorizzate — senza default, `docker compose config` si
rifiuta di procedere se mancano.
Il worker di visione dipende dal submodule `vendor/visionsuite`
(`git clone --recurse-submodules`, vedi il [Quick Start del README](../README.md#quick-start-con-docker))
e richiede `VISION_ENGINE_VERSION` valorizzata **prima** della build, altrimenti
`Dockerfile.vision` fallisce di proposito:
```bash ```bash
# Build images # Sviluppo / on-premise
docker-compose build VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
docker compose -f docker-compose.dev.yml up -d --build
# Start services # Produzione
docker-compose up -d VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
docker compose -f docker-compose.yml up -d --build
# View logs
docker-compose logs -f
# Stop services
docker-compose down
``` ```
### Manual Docker Build Comandi comuni (sintassi `docker compose` v2, senza trattino — il plugin, non
il vecchio `docker-compose` standalone):
#### API Server
```dockerfile
FROM python:3.11-slim
WORKDIR /app/server
COPY server/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server/ .
COPY .env ..
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```
Build and run:
```bash ```bash
docker build -f server/Dockerfile -t tiemeasureflow-api . docker compose -f docker-compose.yml ps
docker run -p 8000:8000 --env-file .env tiemeasureflow-api docker compose -f docker-compose.yml logs -f server
docker compose -f docker-compose.yml down
``` ```
#### Web Client ### Aggiornare un'installazione esistente
```dockerfile Le immagini precedenti creavano l'utente non privilegiato senza una home
FROM python:3.11-slim directory (`useradd -u 10001 app`), e il volume `upload_data` che ne è
risultato appartiene a `root`. Le immagini attuali creano l'utente con
`-m` e impostano `UV_CACHE_DIR=/tmp/uv-cache`, ma questo non cambia da solo
la proprietà di un volume già esistente: se si riparte con le nuove immagini
su un volume creato da una versione precedente, il container `server` non
riesce a scrivere sotto `/app/uploads`.
WORKDIR /app/client Prima del primo avvio con le nuove immagini, va quindi corretta la proprietà
del volume:
COPY client/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY client/ .
COPY .env ..
EXPOSE 5000
CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:5000", "app:app"]
```
Build and run:
```bash ```bash
docker build -f client/Dockerfile -t tiemeasureflow-web . docker compose -f docker-compose.yml run --rm --user root --entrypoint chown server -R 10001:10001 /app/uploads
docker run -p 5000:5000 --env-file .env tiemeasureflow-web
``` ```
(sostituire `docker-compose.yml` con `docker-compose.dev.yml` per
un'installazione di sviluppo/on-premise). Il comando usa il servizio
`server` perché è quello che monta il volume `upload_data` in lettura e
scrittura; il worker di visione lo monta in sola lettura e non ne ha
bisogno. Un'installazione nuova, che parte da un volume vuoto, non richiede
questo passo.
--- ---
## SSL/TLS ## SSL/TLS
### Self-Signed Certificate (Development) Le due installazioni gestiscono il TLS in due modi diversi, e nessuno dei due
richiede un certificato generato a mano:
```bash - **Produzione** (`docker-compose.yml`): Traefik ottiene e rinnova da solo un
openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes certificato Let's Encrypt per `APP_DOMAIN`, tramite il `certresolver`
``` configurato nelle label del servizio (`tls.certresolver=mytlschallenge`).
Serve solo che `APP_DOMAIN` punti davvero al server (DNS) e che le porte
80/443 siano raggiungibili da internet per la sfida ACME.
- **On-premise, rete LAN** (`docker-compose.dev.yml`): Nginx serve in HTTP
semplice, senza TLS — è il caso descritto in
[Recommended Architecture](#recommended-architecture). Chi ha comunque
bisogno di un certificato (es. una CA interna) lo aggiunge scommentando la
sezione SSL già presente, ma commentata, in
[`nginx/nginx.conf`](../nginx/nginx.conf).
### Let's Encrypt (Production) Un'installazione manuale (senza Docker, sezioni [Server](#server-deployment) e
[Client Deployment](#client-deployment) sopra) che debba terminare il TLS da
```bash sola, dietro il proprio reverse proxy, può ancora valorizzare
sudo apt install certbot python3-certbot-nginx `SSL_CERTFILE`/`SSL_KEYFILE` in `.env` — variabili lasciate per questo caso,
sudo certbot certonly --nginx -d yourdomain.com non lette da uvicorn/gunicorn nei comandi mostrati sopra: vanno passate al
``` proprio reverse proxy o gestite fuori da questo progetto.
### Configure in .env
```env
SSL_CERTFILE=/etc/ssl/certs/yourdomain.com.crt
SSL_KEYFILE=/etc/ssl/private/yourdomain.com.key
```
### Start Server with SSL
```bash
cd server
gunicorn main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8443 \
--certfile /etc/ssl/certs/yourdomain.com.crt \
--keyfile /etc/ssl/private/yourdomain.com.key
```
--- ---
@@ -644,14 +548,11 @@ sqlalchemy.exc.OperationalError: (asyncmy.errors.ProgrammingError) (2003, "Can't
**Check:** **Check:**
```bash ```bash
# Verify MySQL is running # Verify MySQL is running (con Docker: docker compose ps mysql, ha già un healthcheck)
mysql -u tmflow -p -e "SELECT 1" mysql -u tmflow -p -e "SELECT 1"
# Check .env variables # Check .env variables — non stampare mai la password: si controlla il valore, non la connection string
grep DB_ .env grep DB_ .env
# Test connection string
cd server && python -c "from config import settings; print(settings.database_url)"
``` ```
### Port Already in Use ### Port Already in Use
@@ -667,7 +568,7 @@ lsof -i :8000
kill -9 <PID> kill -9 <PID>
# Or use different port # Or use different port
uvicorn main:app --port 8001 uv run uvicorn src.backend.main:app --port 8001
``` ```
### Import Errors ### Import Errors
@@ -678,21 +579,20 @@ ModuleNotFoundError: No module named 'fastapi'
**Solution:** **Solution:**
```bash ```bash
cd server uv sync --extra server --extra client --extra dev
pip install -r requirements.txt
``` ```
### File Upload Not Working ### File Upload Not Working
``` ```
/app/server/uploads: Permission denied uploads: Permission denied
``` ```
**Solution:** **Solution:**
```bash ```bash
# Create uploads directory with correct permissions # Create uploads directory with correct permissions (radice del repo, non server/uploads)
mkdir -p server/uploads mkdir -p uploads/images uploads/pdfs uploads/logos uploads/reports
chmod 755 server/uploads chmod 755 uploads
``` ```
### Migrations Failed ### Migrations Failed
@@ -703,13 +603,12 @@ alembic.util.exc.CommandError: Can't locate revision identified by...
**Solution:** **Solution:**
```bash ```bash
cd server
# Check migration status # Check migration status
alembic current uv run alembic -c src/backend/migrations/alembic.ini current
# Reset migrations (CAUTION - deletes data) # Reset migrations (CAUTION - deletes data)
alembic downgrade base uv run alembic -c src/backend/migrations/alembic.ini downgrade base
alembic upgrade head uv run alembic -c src/backend/migrations/alembic.ini upgrade head
``` ```
### API Key Not Working ### API Key Not Working
+10 -3
View File
@@ -6,8 +6,11 @@ Indice della documentazione del progetto.
| Documento | Scopo | | Documento | Scopo |
|---|---| |---|---|
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V2.0.0). Snapshot del sistema, componenti e capacità. | | [`../TieMeasureFlow_modifiche_2026-07-28.md`](../TieMeasureFlow_modifiche_2026-07-28.md) | **Il piano di lavoro corrente**: i quindici punti del sopralluogo del 28/07 e le decisioni in attesa del cliente (D-1…D-9). |
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta da fare. Fasi 2-7 della migrazione rev04 verso V1.1.0/M1 demo cliente. | | [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V3.0.0), punto per punto, e cosa non è ancora stato provato sul campo. |
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta, in ordine: collaudo, punto 4, innesto GAIA, installazione a Tràfilo. Include la mappatura con le Fasi rev04 di aprile. |
| [`architecture/LAYOUT.md`](architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava fra le viste e la regola che lo tiene fermo. |
| [`../src/frontend/flask_app/static/vendor/VERSIONS.md`](../src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte SHA-256, come aggiornarle. |
## Riferimenti operativi ## Riferimenti operativi
@@ -17,8 +20,12 @@ Indice della documentazione del progetto.
| [`DEPLOYMENT.md`](DEPLOYMENT.md) | Guida deploy su VPS (Hostinger/Tielogic con Traefik + Let's Encrypt). | | [`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). | | [`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). | | [`I18N_SETUP.md`](I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js). |
| [`COLLAUDO.md`](COLLAUDO.md) | **Scaletta di collaudo V3.0.0**: cosa provare, in che ordine, con quali valori, e come si vede che è giusto. |
## Piani dettagliati TDD (rev04) ## Piani dettagliati TDD (rev04) — storici
> Il piano rev04 di aprile è **superato** dal documento del 28/07. La mappatura fra
> le sue sette Fasi e i quindici punti correnti è in fondo a `architecture/ROADMAP.md`.
| Documento | Scopo | | Documento | Scopo |
|---|---| |---|---|
+98
View File
@@ -0,0 +1,98 @@
# Cornice delle pagine
Nasce dal punto 14 del documento del 28/07: *«le dimensioni delle viste cambiano a
seconda del menu; passando da una schermata all'altra la finestra non mantiene
proporzioni stabili»*. La segnalazione era di un operatore e non era ancora stata
circoscritta sul codice. Questo è l'esito della circoscrizione e la regola che ne
è uscita.
## Cosa succedeva
Tre meccanismi distinti, tutti verificabili sul codice senza avere il tablet in mano.
**1. Ogni vista si dichiarava la propria larghezza.** Sette valori diversi su
diciassette template, e nessuno coincideva con quello della navbar, che sta a
`max-w-7xl` su tutte le pagine. Il contenuto risultava quindi disallineato dalla
barra sopra di sé, e il disallineamento cambiava da pagina a pagina.
| vista | prima | dopo |
|---|---|---|
| `measure/select_recipe.html` | `max-w-7xl` `py-8` | `tmf-page` |
| `measure/task_list.html` | `max-w-5xl` `py-8` | `tmf-page` |
| `measure/task_complete.html` | `max-w-7xl` `py-6` | `tmf-page` |
| `maker/recipe_list.html` | `max-w-6xl` `py-8` | `tmf-page` |
| `maker/task_editor.html` | `max-w-5xl` `py-8` | `tmf-page` |
| `maker/task_drawing.html` | `max-w-5xl` `py-8` | `tmf-page` |
| `maker/recipe_preview.html` | `max-w-5xl` `py-8` | `tmf-page` |
| `admin/stations.html` | `max-w-7xl` `py-6` | `tmf-page` |
| `admin/users.html` | `max-w-7xl` `py-6` | `tmf-page` |
| `statistics/dashboard.html` | `max-w-7xl` `py-6` | `tmf-page` |
| `admin/settings.html` | `max-w-3xl` `py-6` | `tmf-page tmf-page-narrow` |
| `auth/profile.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
| `maker/recipe_editor.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
| `maker/version_history.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
| `errors/station_not_configured.html` | `max-w-2xl` `py-8` | `tmf-page tmf-page-narrow` |
Il percorso dell'operatore — quello da cui è arrivata la segnalazione — faceva
`1280px → 1024px → tutto schermo → 1280px` in quattro passaggi.
**2. Il padding verticale non era lo stesso.** `py-8` e `py-6` mescolati fra viste
consecutive: la prima card si trovava a un'altezza diversa a ogni cambio di
schermata.
**3. La barra di scorrimento appariva e spariva.** È una barra classica da 8px
(`themes.css`, `::-webkit-scrollbar`), quindi occupa spazio nel layout. Una pagina
lunga la mostrava, una corta no, e la schermata di misura la toglie sempre
(`body { overflow: hidden }`): a ogni navigazione tutto il contenuto centrato —
navbar compresa — si spostava di 8px in orizzontale.
## La regola
Due classi in `static/css/themes.css`, nessuna larghezza nei template.
- **`.tmf-page`** — la cornice: `max-width: 80rem`, padding `1rem / 1.5rem / 2rem`
ai tre breakpoint, `padding-block: 1.5rem`. Sono esattamente i valori della
navbar (`max-w-7xl px-4 sm:px-6 lg:px-8`), così il contenuto ci si allinea.
- **`.tmf-page-narrow`** — si aggiunge alla prima e stringe a `56rem`. È per i
moduli: un campo di testo largo 1280px non si compila meglio.
- **`html { scrollbar-gutter: stable }`** — lo spazio della barra è riservato
sempre, anche dove la pagina non scorre.
- **`body.h-screen { height: 100dvh }`** — la schermata di misura è alta quanto la
finestra vera. `100vh` su Android e iOS è l'altezza che la pagina avrebbe con la
barra dell'indirizzo nascosta: su un tablet il piede della schermata — dove
stanno «Fine ciclo misura» e il tastierino — finiva sotto il bordo, e ricompariva
quando la barra si ritraeva.
Due larghezze in tutto il prodotto al posto di sette, e il criterio è il tipo di
pagina: liste, tabelle e tele di disegno stanno larghe, i moduli stanno stretti.
## Le due eccezioni
Hanno geometria propria per un motivo, e sono elencate in
`tests/test_layout_shell.py` perché non sembrino dimenticate.
- **`auth/login.html`** — schermata piena senza navbar: non c'è niente a cui
allinearsi.
- **`measure/task_execute.html`** — la schermata di misura è un pannello a tutta
altezza che non deve scorrere mentre un operatore sta misurando
(`h-screen overflow-hidden`, footer nascosto). È l'unica vista in cui il cambio
di forma è voluto, ed è anche l'unica in cui l'operatore si ferma a lavorare:
entrarci e uscirne resta un salto, ma ora è l'unico.
## Cosa resta da verificare sul campo
La segnalazione non è mai stata riprodotta su un dispositivo: quanto sopra viene
dalla lettura del codice, non da una prova. Quello che è dimostrato è che il
layout *poteva* muoversi per quattro motivi distinti e che ora non può più per
nessuno dei quattro.
Se l'operatore vedesse ancora spostamenti, il sospetto successivo è la **tastiera
virtuale**: quando compare, il browser ridimensiona la finestra, e con `100dvh` la
schermata di misura si ridimensiona con lei invece di scorrere sotto. È il
comportamento giusto per il tastierino a schermo, ma va guardato con un calibro
USB collegato, dove la tastiera di sistema non dovrebbe comparire affatto.
Da chiedere all'operatore che ha aperto la segnalazione: **su quale schermo** l'ha
vista. Su un tablet in verticale la maggior parte delle larghezze qui sopra
collassa allo stesso valore e il difetto non si vede; su un monitor da scrivania
si vede tutto.
+126 -75
View File
@@ -1,95 +1,146 @@
# Roadmap TieMeasureFlow — V2.0.0 → V1.1.0 (rev04 / M1 demo cliente) # Roadmap TieMeasureFlow — V3.0.0 → installazione a Tràfilo
> Aggiornare ad ogni Fase chiusa. > Aggiornata al 2026-07-28. Aggiornare a ogni punto chiuso.
## Riferimenti ## Dove siamo
- Master plan dettagliato: [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md) La roadmap rev04 di aprile (Fasi 1-7, milestone M1/M2) è **superata dai fatti**: il
- Spec sorgente: [`../specs/2026-04-16-schema-sviluppo-rev04.docx`](../specs/2026-04-16-schema-sviluppo-rev04.docx) sopralluogo del 28/07 ha prodotto un elenco di quindici punti concreti che è oggi il
- Stato corrente: [`STATO_PROGETTO.md`](STATO_PROGETTO.md) piano di lavoro. Buona parte delle vecchie Fasi 2 e 4 è dentro quei punti ed è già
fatta; il resto è confluito o decaduto. La mappatura è in fondo, per non perdere il
filo con i documenti di aprile.
## Strategia: due milestone - Piano corrente: [`../../TieMeasureFlow_modifiche_2026-07-28.md`](../../TieMeasureFlow_modifiche_2026-07-28.md)
- Stato di dettaglio: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
- Piano rev04 (storico): [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md)
| Milestone | Scope | Obiettivo | **Scadenza che comanda tutto: l'installazione on-premise a Tràfilo è prevista per
settembre 2026.**
## I quindici punti
| # | Punto | Stato |
|---|---|---| |---|---|---|
| **M1 — Demo cliente** | Fasi 1-5 + deploy "demo" | Sistema testabile end-to-end col cliente per raccogliere feedback | | 1 | Memoria della produzione in corso | ✅ fatto |
| **M2 — Produzione** | Fasi 6-7 + correzioni post-feedback + GAIA live | Rollout su tablet/PC reali | | 2 | Tipo di task esplicito | ✅ fatto |
| 3 | Loop di misura e ripetizione | ✅ fatto |
| 4 | Limite di tentativi prima del capoturno | ⛔ **fermo** su D-6, D-7, D-9 |
| 5 | Avanzamento solo se in tolleranza | ✅ fatto |
| 6 | Fermo linea e Fine produzione con effetto | ✅ fatto (innesto GAIA fermo su D-1, D-2) |
| 7 | Gestione delle stazioni | ✅ fatto |
| 8 | Tracciabilità obbligatoria | ✅ fatto |
| 9 | Blocco dell'inserimento manuale | ✅ fatto |
| 10 | Interfaccia operatore: sequenza e conferme | ✅ fatto |
| 11 | Formattazione delle descrizioni | ✅ fatto |
| 12 | Funzionamento senza internet | ✅ fatto |
| 13 | Generazione task dalla scheda tecnica con l'AI | — fuori offerta (quotazione su D-8) |
| 14 | Stabilità del layout | ✅ fatto |
| 15 | Statistica separata dalla registrazione | ✅ fatto |
## Stato Fasi (M1) ## Cosa resta, in ordine
| Fase | Scope | Stato | Branch / Commit | ### 1. Collaudo sul campo — *da fare adesso, non serve nessuna risposta*
È il lavoro più urgente rimasto, ed è l'unico che non dipende da nessuno. Tredici
punti sono in esercizio e **nessuno li ha percorsi su un tablet**.
Le ricette `COLLAUDO-A` e `COLLAUDO-B` sono già sul sistema, con un utente
`capoturno` (`scripts/seed_collaudo.py`). Da verificare, in quest'ordine:
| Cosa | Come si vede che è giusto |
|---|---|
| Ciclo di misura (3) | Il conto alla rovescia sopravvive al cambio task, prosegue in rosso oltre lo zero, riporta alla misura |
| Fuori tolleranza (5) | Con `12.00` sulla prima quota non si passa alla seconda finché il capoturno non autorizza, o finché non si rimisura dentro |
| Tracciabilità (8) | Su `COLLAUDO-B` l'avvio non parte finché lotto e seriale non ci sono |
| Inserimento manuale (9) | Su `COLLAUDO-B` il tastierino numerico non c'è affatto |
| Sequenza (10) | La ricetta si apre sul primo task; un task lasciato a metà appare «Incompiuto 1/2» nella lista |
| Senza rete (12) | Staccare la rete e percorrere login → ricetta → task → annotazione → statistiche → report, console aperta |
| Layout (14) | **Da un monitor da scrivania**, non solo dal tablet: è lì che il difetto si vedeva |
Il giro del punto 12 va fatto **ora**, perché la Content-Security-Policy è appena
entrata in vigore: se una libreria avesse bisogno di un permesso non concesso, il
sintomo compare adesso.
### 2. Punto 4 — appena arrivano le risposte
Numero di tentativi prima del capoturno. Serve sapere **D-7** (quanti, e se uguali
per tutte le ricette), **D-6** (password, PIN o badge) e **D-9** (se cambiare i
parametri crea una nuova versione). Il resto dell'impianto è pronto: il blocco per
fuori tolleranza e l'autorizzazione del capoturno esistono già, manca il contatore.
Stima, a decisioni chiuse: **2-3 giorni**.
### 3. Innesto verso GAIA — fermo su D-1 e D-2
Avvio produzione, fermo linea e fine produzione hanno già lo stato e gli eventi; la
fine produzione emette già il file di statistica. Manca solo il canale verso il
gestionale, e non si può nemmeno disegnare finché non si sa **come** si parla con
GAIA (D-1) e **da dove** (D-2).
Stima, a protocollo noto: **1-2 settimane**, molto dipendente dalla risposta.
### 4. Installazione a Tràfilo — settembre
| Cosa | Blocco |
|---|---|
| Macchina server, spazio disco | D-4 |
| Immagini Docker portate in reparto, non repository da compilare sul posto | — |
| Colonnina luminosa, se serve | D-5 |
| Validazione rete e macchine con l'IT | D-4 |
Nota: **la costruzione delle immagini richiede rete** (npm, apt, uv); è l'esecuzione
a non richiederla. In reparto va portata l'immagine già costruita.
## Decisioni in attesa del cliente
Da girare a Tràfilo tramite Menoncin. Le prime due e la D-4 hanno l'orizzonte di
settembre; D-6 e D-7 bloccano lavoro che sappiamo già fare.
| ID | Decisione | Blocca | Chi risponde |
|---|---|---|---| |---|---|---|---|
| **1** | Stazioni + identità per-tablet | ✅ **COMPLETATA** | `V2.0.0` (merge `ea8e468` da `feature/rev04-phase1-stations`) | | **D-1** | Protocollo del gestionale GAIA: servizi web, database condiviso, file? | innesto GAIA (punto 6) | IT Tràfilo + fornitore GAIA |
| 2 | Ruolo Capoturno (Supervisor) + override token breve | ⏳ Da iniziare | — | | **D-2** | Rete e credenziali per raggiungere GAIA | come sopra | IT Tràfilo |
| 3 | Editor ricetta a blocchi (preparation + measurement) | ⏳ Da iniziare | — | | ~~D-3~~ | ~~Una app per stazione o una sola?~~ **Decisa il 28/07**: dati sul server, app di stazione su ogni PC | — | chiusa; validazione in D-4 |
| 4 | Workflow operatore (retry/timer/autologout/avvio produzione) | ⏳ Da iniziare | — | | **D-4** | Server: quale macchina, quanto spazio disco (database **e** disegni) | installazione | IT Tràfilo |
| 5 (M1) | `ImportOnlyGaiaClient` + UI import dati cliente reali | ⏳ Da iniziare | — | | **D-5** | Cicalino: basta il suono del browser o serve una colonnina luminosa? | punto 3 (completamento) | Tràfilo |
| Deploy M1 | VPS demo (compose + Traefik + LE, no registry) | ⏳ Da iniziare | — | | **D-6** | Autorizzazione capoturno: password come oggi, o PIN / badge? Venti volte al giorno la password è attrito | punti 4, 5, 6 | Tràfilo |
| **D-7** | Quanti tentativi prima del capoturno, e uguali per tutte le ricette? | punto 4 | Tràfilo |
| **D-8** | Schede tecniche: quante, formato standard, conversione una-tantum o funzione permanente? | punto 13 e la sua quotazione | Tràfilo |
| **D-9** | Cambiare i parametri di una ricetta (timer, tentativi) crea una nuova versione? | punti 3, 4 | noi, con conferma cliente |
## Stato Fasi (M2) Una decisione già presa che va **confermata**: la migrazione 009 ha impostato
`allow_manual_input = 1` su tutte le ricette esistenti, per conservare il
comportamento in essere invece di irrigidire di colpo ricette già in uso. Va deciso
ricetta per ricetta quali devono passare a solo calibro.
| Fase | Scope | Stato | ## Tech debt
|---|---|---|
| 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 | | Item | Priorità | Note |
|---|---|---| |---|---|---|
| 3 test backend pre-esistenti rotti (`test_recipes`, `test_tasks`) | Media | Investigare prima di Fase 3 (toccano recipe + task router). | | `components/barcode_scanner.html` dipende da `html5-qrcode`, mai caricata | Media | Codice morto: il componente non è incluso da nessuna parte e il lettore che l'operatore usa è un campo di testo. Da rimuovere, o da completare portando la libreria in `static/vendor/` |
| 1 test client pre-esistente rotto (`test_save_measurement_proxy`) | Bassa | Probabilmente CSRF/payload. Risolvere con Fase 4. | | Pagina `task_complete`: riga vuota segnalata in alcuni scenari | Media | Segnalazione di aprile mai riprodotta. Da verificare durante il collaudo, ora che il flusso è cambiato |
| 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: impatta i deploy esistenti |
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato (impatto deploy). | | Header `X-API-Key` → `X-Api-Key` | Bassa | Breaking per i deploy esistenti |
| Header `X-API-Key` rename a `X-Api-Key` | Bassa | Vedere se M2 lo richiede. | | Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 |
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 in M2. | | Test di carico a 20 tablet reali | Bassa | La capacità è dimensionata ma mai misurata sotto carico vero |
| `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 I quattro test rotti tracciati nello snapshot V2.0.0 non risultano più: la suite è a
360 pass, 0 fail.
1. **Quale fase iniziare adesso?** Opzioni: Fase 2 (Supervisor — sblocca workflow), Fase 3 (block editor — independente), Fase 5 (import GAIA dati reali — sblocca demo). ## Mappatura con la roadmap rev04 di aprile
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) Per chi torna sui documenti di aprile e non ritrova le Fasi.
| Task | Stima full-time | | Fase rev04 | Che fine ha fatto |
|---|---| |---|---|
| Fase 2 — Supervisor + override | 1 settimana | | 1 — Stazioni per-tablet | Chiusa in V2.0.0, ampliata dal punto 7 |
| Fase 3 — Block editor | 1.5 settimane | | 2 — Ruolo Capoturno + override | Assorbita dai punti 5 e 6: il ruolo `Supervisor` esiste e l'autorizzazione resta scritta sulla misura. L'override a token breve dipende da D-6 |
| Fase 4 — Workflow operatore | 2 settimane | | 3 — Editor ricetta a blocchi | Sostituita dal punto 2 (tipo di task dichiarato), che risolve il problema vero senza riscrivere l'editor |
| Fase 5 (M1) — Import-only GAIA | 1 settimana | | 4 — Workflow operatore | Assorbita dai punti 3, 4, 10: timer e sequenza sono fatti, i tentativi sono il punto 4 |
| Deploy M1 demo | 0.5 settimane | | 5 — Import GAIA | Diventata l'innesto del punto 6, ferma su D-1 e D-2 |
| **Totale M1 residuo** | **~6 settimane** | | 6 — Deploy industriale (registry + Watchtower) | Ridimensionata: con una rete isolata l'aggiornamento automatico non ha senso. Restano immagini versionate portate a mano |
| 7 — Hardening | Parzialmente assorbita: CSP, versioni congelate e impronte sono entrate col punto 12 |
## Stima tempi M2 (dopo feedback) Le vecchie decisioni `D-0.x` sono confluite nelle `D-x` qui sopra: D-0.1→D-1,
D-0.2→D-2, D-0.4→D-5, D-0.5→D-9, D-0.6→D-6. D-0.8 (nome del ruolo capoturno) è
| Task | Stima | chiusa: si chiama `Supervisor`. D-0.7 (auto-logout) era già risolta.
|---|---|
| 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.
+157 -110
View File
@@ -1,145 +1,192 @@
# Stato Progetto TieMeasureFlow — V2.0.0 # Stato Progetto TieMeasureFlow — V3.0.0
> Snapshot al 2026-04-27. Aggiornare ad ogni milestone. > Snapshot al 2026-07-28. Aggiornare ad ogni milestone.
> Lo snapshot V2.0.0 è in [`../archive/STATO_PROGETTO_V2.0.0_2026-04-27.md`](../archive/STATO_PROGETTO_V2.0.0_2026-04-27.md).
## Versione corrente ## Versione corrente
**V2.0.0** (in sviluppo, branch `V2.0.0` come default su `git.tielogic.xyz`). **V3.0.0** (in sviluppo, branch `V3.0.0`). Versione precedente: `V2.0.0`.
Versione precedente di produzione: `V1.0.7`. In esercizio su `tieflow.tielogic.xyz` (Docker Compose + Traefik + Let's Encrypt),
schema alla migrazione `010_meas_authorisation`.
## Sintesi esecutiva ## 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/`). V2.0.0 aveva chiuso stazioni per-tablet, ristrutturazione monorepo con `uv` e la
tenuta multi-tablet. **V3.0.0 nasce dal sopralluogo del 28/07 e lavora sui quindici
punti** raccolti in [`TieMeasureFlow_modifiche_2026-07-28.md`](../../TieMeasureFlow_modifiche_2026-07-28.md).
## Cosa funziona oggi (V2.0.0 — branch corrente) Il filo che li tiene insieme: fino a V2.0.0 il sistema *registrava* misure ma non
*governava* la produzione. Le regole vivevano nell'interfaccia — una modale che si
poteva chiudere, un timer che moriva al cambio pagina, un tipo di task dedotto dalla
presenza di quote. V3.0.0 le sposta sul server, dove non si aggirano.
### Funzionalità ereditate da V1.0.7 Tredici punti su quindici sono fatti e in esercizio. Uno è fermo sulle risposte del
- Autenticazione username/password + API key per-utente, ruoli combinabili (Maker, MeasurementTec, Metrologist) + flag `is_admin`. cliente, uno è fuori offerta.
- 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) ## I quindici punti
- 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) | # | Punto | Stato | Dove vive |
- Gunicorn 5 workers × 4 thread (gthread) — capacità ~20 richieste concorrenti Flask, regge 20+ tablet. |---|---|---|---|
- Uvicorn 4 workers + `--proxy-headers --forwarded-allow-ips='*'`. | 1 | Memoria della produzione in corso | ✅ | `production_runs`, `production_service` |
- Rate limit middleware: identificazione IP reale via `X-Forwarded-For` → `X-Real-IP` → `request.client.host`. | 2 | Tipo di task esplicito | ✅ | `RecipeTask.task_type`, migrazione 007 |
- Rate limit general 100 → 300 req/min/IP (per-tablet ora, non più condiviso). | 3 | Loop di misura e ripetizione | ✅ | `production-clock.js`, `production_service`, migrazione 008 |
- Flask `ProxyFix(x_for=1, x_proto=1, x_host=1)` per IP reale dietro Nginx. | 4 | Limite di tentativi prima del capoturno | ⛔ **Fermo su D-6, D-7, D-9** | — |
- `APIClient` propaga `X-Forwarded-For` + `X-Real-IP` (sia JSON che multipart). | 5 | Avanzamento solo se in tolleranza | ✅ | `measurement_service.pending_authorisation`, migrazione 010 |
- 12 test aggiuntivi (7 server + 5 client). | 6 | Fermo linea e Fine produzione con effetto | ✅ | `production_service`, `production_export_service` |
| 7 | Gestione delle stazioni | ✅ | `station_service`, `/admin/stations` |
| 8 | Tracciabilità obbligatoria | ✅ | `Recipe.requires_lot/serial`, migrazione 009 |
| 9 | Blocco dell'inserimento manuale | ✅ | `Recipe.allow_manual_input`, `numpad.js` |
| 10 | Interfaccia operatore: sequenza e conferme | ✅ | `/measure/start`, `task-progress`, `task_list`, `task_execute` |
| 11 | Formattazione delle descrizioni | ✅ | filtro `rich_text`, `rich-text.js` |
| 12 | Funzionamento senza internet | ✅ | `static/vendor/`, CSP in `app.py` |
| 13 | Generazione task dalla scheda tecnica con l'AI | — **Fuori offerta** | — |
| 14 | Stabilità del layout | ✅ | `.tmf-page` in `themes.css`, [`LAYOUT.md`](LAYOUT.md) |
| 15 | Statistica separata dalla registrazione | ✅ | `@role_required("Metrologist")`, test dedicato |
### Aggiunte V2.0.0 (struttura monorepo) ## Cosa è entrato in V3.0.0
- `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) ### Produzione come stato, non come pulsante (punti 1, 6)
Sequenza di smoke test in locale (uvicorn + gunicorn + MySQL Docker) ha fatto emergere quattro regressioni che sarebbero rimaste invisibili al test suite: - Tabelle `production_runs` e `production_events`; una produzione si apre su una
stazione, accumula eventi (`start`, `cycle_completed`, `task_measured`,
`remeasure`, `line_stop`, `resume`, `close`) e si chiude.
- Fermo linea, ripresa e fine produzione richiedono le credenziali del capoturno e
**cambiano lo stato**: prima erano modali che si chiudevano.
- La fine produzione emette il file di statistica dell'intera produzione
(`production_export_service`), CSV con i delimitatori configurati, marcando le
misure come esportate. Il punto d'innesto verso GAIA è lì accanto, in attesa di
D-1 e D-2.
- **`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"`. ### Il ciclo di misura (punto 3)
- **`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). - L'intervallo della ricetta è calcolato dal server (`seconds_to_next_measurement`,
`overdue`, `server_time`): sopravvive al cambio task, che è un ricaricamento di
pagina, e non si azzera cambiando schermata.
- Il conteggio **prosegue oltre lo zero**: il ritardo si vede, in rosso.
- A scadenza l'operatore viene riportato al primo task di misura della ricetta, con
cinque secondi di preavviso; chi sta già misurando viene lasciato finire.
- Cicalino via WebAudio, nessun file audio da caricare.
- `remeasure` distingue una seconda lettura dello stesso pezzo da un ciclo nuovo:
girare il pezzo e rimisurare non deve far ripartire l'intervallo.
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. ### Le regole della ricetta (punti 8, 9)
## Layout repository (V2.0.0) - `requires_lot`, `requires_serial`, `allow_manual_input` per ricetta, verificati in
`measurement_service`: **ogni porta d'ingresso passa di lì**, barcode compreso.
- Con `allow_manual_input` a falso il tastierino non viene disegnato — non nascosto:
il markup non esiste — e il server rifiuta comunque un valore digitato a mano.
- Il rilevamento del calibro USB è stato reso più tollerante (Enter veloce), perché
con la regola stretta una lettura corta come `9.5` veniva scambiata per digitazione.
``` ### Il fuori tolleranza (punto 5)
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 - Una quota fuori tolleranza e non autorizzata **blocca** il salvataggio della quota
successiva e la chiusura del ciclo (409).
- Conta l'**ultima** lettura di ogni quota: rimisurare la stessa quota resta
possibile — il calibro scivola, il pezzo si riposiziona — e una lettura dentro i
limiti scioglie il blocco senza chiamare nessuno.
- L'autorizzazione del capoturno resta scritta sulla misura (`supervisor_id`,
`authorised_at`) e finisce in due colonne del CSV di produzione: il file mostra il
guasto **e** la decisione.
- Quanti tentativi siano ammessi è il punto 4, non questo.
Validazione end-to-end in locale (2026-04-26): ### L'interfaccia dell'operatore (punti 10, 11, 14, 15)
- ✅ MySQL container Docker up, schema creato, alembic stamp head OK - La ricetta si apre sul primo task (`/measure/start/<id>`), non su un elenco. La
- ✅ uvicorn `--reload` su :8000, `/api/health` risponde lista scende a secondo livello e mostra quali task sono rimasti incompiuti
- ✅ Seed `/api/setup/seed` con `SETUP_PASSWORD=adriano77` → admin + 4 utenti demo + DEMO-001 + ST-DEFAULT con assegnazione automatica (`GET /api/measurements/task-progress`, contato per quota e non per tentativo).
- ✅ Login `admin/admin123` via web, sessione persistente - «Fine ciclo misura» è visibile da subito e spento finché mancano quote, con
- ✅ `/admin/stations`: tabella, modal create/edit, modal gestione assegnazioni a 2 colonne con search, eliminazione con cascade scritto quante ne mancano.
- ✅ `/admin/users`, `/maker/recipes`, `/measure/select` (filtrato per stazione), `/statistics/dashboard` - Descrizioni con `**grassetto**` e a capo: marcatura, non HTML, così la
- ✅ Workflow MeasurementTec end-to-end: select_recipe → task_list → task_execute → task_complete (riepilogo con misure) sanificazione è per costruzione.
- ✅ Hot reload Flask + uvicorn `--reload` + Tailwind watch attivi durante lo sviluppo - Una cornice sola per tutte le viste, allineata alla navbar — vedi [`LAYOUT.md`](LAYOUT.md).
- Nessun percorso dell'operatore porta alla statistica, ed è un test.
### Fuori dalla rete (punto 12)
- Alpine, Plotly, PDF.js (+ worker), Fabric e i font sono nell'installazione, con
versione nel nome e impronta SHA-256 verificata da un test.
- Content-Security-Policy a sola origine locale servita **dal client Flask**: prima
esisteva solo sul backend, cioè sulle risposte API e non sulle pagine.
- Tailwind fissato a `3.4.19` nel `Dockerfile.frontend`.
## Migrazioni
| # | Contenuto |
|---|---|
| 001–004 | image_path, stazioni, intervallo di misura + auto-logout, `input_duration_ms` |
| 005–006 | `production_runs`, misure legate alla produzione |
| 007 | `task_type` dichiarato |
| 008 | tipi di evento del ciclo + `task_id` sull'evento |
| 009 | `requires_lot`, `requires_serial`, `allow_manual_input` |
| 010 | `supervisor_id`, `authorised_at` sulla misura |
Le 008 e 010 usano il *batch mode* per compatibilità SQLite (i test) e sono state
verificate anche in resa MySQL (`alembic upgrade X:Y --sql`) prima del deploy.
La 009 imposta `allow_manual_input = 1` sulle ricette esistenti: conserva il
comportamento in essere invece di irrigidire di colpo ricette già in uso. È una
scelta, e va confermata dal cliente ricetta per ricetta.
## Test status ## Test 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 | | Test | Fail |
|---|---|---|
| Backend (`src/backend/tests/`) | 212 | 0 |
| Frontend (`src/frontend/flask_app/tests/`) | 148 | 0 |
| **Totale** | **360** | **0** |
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 + WeasyPrint + Plotly/Kaleido. I quattro fallimenti pre-esistenti tracciati nello snapshot V2.0.0 non ci sono più.
- **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. Tre file di test non renderizzano niente e leggono i sorgenti, perché guardano
- **Tooling:** uv (package mgmt), pytest + pytest-asyncio + httpx + aiosqlite (test). proprietà che sopravvivono solo se qualcuno le controlla:
| File | Cosa impedisce |
|---|---|
| `test_offline.py` | Una libreria caricata dalla rete, un worker PDF.js lasciato sul CDN, una libreria sostituita senza aggiornare l'impronta |
| `test_layout_shell.py` | Una vista che torna a dichiararsi la propria larghezza |
| `test_template_js_syntax.py` | Una traduzione con l'apostrofo dentro una stringa JS a virgolette singole, che spegne Alpine su tutta la pagina |
## Cosa non è stato provato
Va detto perché non si confonda «i test passano» con «funziona in reparto».
- **Il collaudo sul campo non è mai stato percorso.** Le ricette `COLLAUDO-A` e
`COLLAUDO-B` sono state seminate sul sistema in esercizio (`scripts/seed_collaudo.py`)
insieme a un utente `capoturno`, ma nessuno ha ancora guidato il flusso su un
tablet vero.
- **Il punto 14 non è stato riprodotto su un dispositivo**: è dimostrato che il
layout *poteva* muoversi per quattro motivi e che ora non può più, non che
l'operatore vedesse esattamente quelli.
- **Il punto 12 non è stato provato a rete staccata.** È dimostrato che nessuna
risorsa esterna viene richiesta. Il giro con la rete staccata va fatto, ed è ora il
momento giusto perché la CSP è appena entrata in vigore.
- **Il cicalino** è il suono del browser. Se serva una colonnina luminosa è D-5.
## Stack
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 +
WeasyPrint + Plotly/Kaleido. 12 router.
- **Frontend:** Flask + Jinja2 + Alpine.js 3.15.12 + TailwindCSS 3.4.19 +
Fabric.js 5.3.1 + PDF.js 3.11.174 + Plotly.js 2.32.0 + Flask-Babel —
**tutte copie locali**, nessun CDN.
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt.
- **Tooling:** uv, pytest + pytest-asyncio + httpx + aiosqlite.
## Decisioni architetturali rilevanti ## Decisioni architetturali rilevanti
| Decisione | Stato | Note | | 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. | | Le regole di misura stanno sul server, non sullo schermo | **Confermata (V3.0.0)** | Tracciabilità, inserimento manuale, fuori tolleranza: la schermata può nasconderle, il server le rifiuta. |
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, no microservizi. Nessuno stub `nats_client/` creato. | | Una app di stazione per PC, dati sul server | **Decisa il 28/07** (D-3) | Validazione con l'IT di Tràfilo aperta come D-4. |
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo 4-5gg refactor + rotture client. Eventuale v2 API in M2. | | Descrizioni in marcatura, non in HTML | **Confermata** | Niente HTML accettato in ingresso: la sanificazione è per costruzione, non per filtro. |
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare costa 50+ punti di codice + breaking per deploy. Rivedere in M2. | | Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, calibri USB, editor Fabric.js, i18n Babel collaudato. |
| Variabili `.env` (DB_HOST, SERVER_PORT, ...) | **Mantenute attuali** | Rename a SERVICE_NAME/SERVICE_DOMAIN/API_KEY rinviato (impatta deploy esistenti). | | NATS messaging (spec §7) | **Skippato** | Monorepo single-host, nessun microservizio. |
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo alto, rotture client. Eventuale API v2. |
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare è breaking per i deploy esistenti. |
## Branch git ## Branch git
- **Default:** `V2.0.0` (lavoro corrente) - **Corrente:** `V3.0.0`
- **Mantenuti:** `V1.0.0` … `V1.0.7` (release branches storiche) - **Precedenti:** `V2.0.0`, `V1.0.0` … `V1.0.7` (release storiche)
- **Mergiato e chiuso:** `feature/rev04-phase1-stations` (in `V2.0.0` con commit `ea8e468`)
@@ -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`)
@@ -0,0 +1,367 @@
# La visione in TieMeasureFlow — stato verificato e specifica del lavoro che manca
> Progetto: TieMeasureFlow, ramo **`V3.0.0`** (HEAD `39a6f62`, 17/08/2026)
> Data: 14 settembre 2026
> Stato: specifica da approvare
> Sostituisce, come documento operativo: il piano `docs/superpowers/plans/2026-08-16-vision-fondamenta-server.md`
> (eseguito) e la corsia `A1-tiemeasureflow.md` del piano ferie (eseguita, cancellata dal disco il 01/09 e
> recuperabile con `git show 874a7a4^:docs/plans/corsie-fase2/A1-tiemeasureflow.md` nel repo AI-OS).
> **Resta in vigore** `docs/superpowers/specs/2026-08-16-visionsuite-integration-design.md`: le sue otto
> decisioni architetturali non vengono riscritte qui, vengono richiamate.
## 1. Perché questo documento
Fra il 16 e il 17 agosto sono entrate in TieMeasureFlow le fondamenta della visione: ventiquattro commit,
sette file di test nuovi, tre migrazioni. Da allora il lavoro si è fermato. Nel frattempo VisionSuite è
andato avanti di sessantasette commit, la scheda di commessa non registra nulla dopo il 25 agosto, e
l'installazione a Tràfilo, prevista per settembre, è alle porte insieme alla fiera del 27-29 ottobre.
Questo documento fa due cose. Prima dichiara, verificandolo sul codice e non sui piani, **che cosa esiste
davvero** sul ramo `V3.0.0`. Poi specifica **che cosa manca** per arrivare a ciò che il cliente ha chiesto
il 27 luglio: pattern matching, confronto del profilo con il disegno DXF e due o tre quote lineari, dentro
l'interfaccia di TieMeasureFlow, con l'esito OK/warning/fail che il prodotto già sa dare.
La distanza fra le due cose è più grande di quanto i documenti di agosto lascino credere, e la ragione è
una sola, spiegata al capitolo 3.
## 2. Quello che esiste, verificato sul ramo V3.0.0
Ogni riga di questa tabella è stata controllata aprendo il file indicato nel clone del ramo `V3.0.0` il
14/09/2026. Non è lo stato dichiarato da un piano: è lo stato del disco.
| Cosa | Stato | Dove si vede |
|---|---|---|
| VisionSuite come sottomodulo git, su commit fisso | ✅ | `.gitmodules`, pin `f095fcc` (15/08) |
| Runner unico, senza HTTP né database | ✅ | `src/vision/runner.py`, 129 righe |
| Versione del motore timbrata su ogni numero prodotto | ✅ | `runner.engine_version()`, errore se non risolve |
| Vision worker come servizio separato nel compose | ✅ | `src/vision_worker/main.py`, `Dockerfile.vision`, servizio `vision` |
| Il server non importa mai VisionSuite | ✅ e **sorvegliato** | `src/backend/tests/test_vision_boundary.py` |
| Grafo di visione sul task (`vision_json`) | ✅ | migrazione `011`, `models/orm/task.py` |
| Legame quota del grafo → quota della ricetta (`vision_output`) | ✅ | migrazione `011`, `recipe_subtasks` |
| `camera` fra i metodi di ingresso misura | ✅ | migrazione `011`, enum `input_method` |
| Tabella `vision_results`, una riga per acquisizione | ✅ | migrazione `012`, `models/orm/vision.py` |
| Immagini di riferimento con provenienza | ✅ | migrazione `013`, `vision_reference_images` |
| Il verdetto lo dà TieMeasureFlow, non il grafo | ✅ | `vision_service` chiama `measurement_service` |
| Gate del fuori tolleranza valutato per acquisizione | ✅ | `enforce_tolerance_gate=False` più gate a monte |
| Copy-on-write delle ricette che copia anche il grafo | ✅ | `test_vision_copy_on_write.py` |
| Il refuso `xf_compare` corretto in `dxf_compare` | ✅ | migrazione `011`, prima che una riga lo usasse |
| Limiti di tipo e dimensione sugli endpoint di upload | ✅ | `test_vision_upload_limits.py` |
| Il guasto del worker resta del worker | ✅ | `vision_service._call_worker`, 502/504 nominati |
| L'applicazione parte senza internet | ✅ | `static/vendor/`, `test_offline.py` |
| API `POST /api/vision/execute`, `/preview`, `/reference-images` | ✅ | `api/routers/vision.py` |
Trentanove test di visione fra `src/backend/tests/test_vision_*.py`, `src/vision/tests/` e
`src/vision_worker/tests/`. ⚠️ **Non li ho eseguiti**: l'albero della visione richiede Python 3.13 e il
sottomodulo inizializzato, cioè un'installazione da alcuni gigabyte che su questa macchina non c'è. Il
conteggio è dei file; la loro verdezza è dichiarata dai commit del 16-17 agosto e va riverificata da chi
riprende, prima di scriverci sopra.
**In sintesi: le fondamenta ci sono e sono fatte bene.** Il confine architetturale è difeso da un test
invece che da una buona intenzione, la provenienza di ogni numero è tracciata, e il modello dati regge il
resto del lavoro senza ristrutturazioni.
## 3. Il buco strutturale: pattern matching e DXF compare non sono eseguibili dal grafo
> ✅ **Colmato in VisionSuite il 14/09/2026** (commit `6f5990c`, issue #2). Quello che segue è la
> diagnosi com'era, e resta perché spiega perché il ponte sta dove sta. Il seguito, cioè cosa manca
> ancora **qui**, è nella fase V1.
È il punto che i documenti di agosto non dicono, ed è quello che decide il calendario.
Il runner esegue **un grafo `vs-task`**: `Task.from_dict(graph)`, poi `task.run(context)`. Gli strumenti
che un grafo può contenere sono quelli registrati in `vs-task`, e al 14/09 — sia sul commit fissato
`f095fcc`, sia sull'ultimo commit di VisionSuite — sono **dieci, tutti geometrici**:
`DetectLine`, `DetectCircle`, `CircleCenter`, `PointIntersectionLL`, `Distance2P`, `DistancePL`,
`DistanceCL`, `Angle2L`, `Radius`, `Diameter`.
Nessuno strumento di pattern matching. Nessuno strumento di confronto DXF. Una ricerca di `pm2d`,
`shape_model` o `dxf` dentro `packages/vs-task/src` non trova nulla.
Coerentemente, in `pyproject.toml` gli extra `vision` e `vision-worker` installano `visionsuite` (core),
`vs-task`, `vs-measure`, `vs-pm2d` e `numpy`: **`vs-dxf` non è installato affatto**, e `vs-pm2d` è
installato ma il grafo non ha modo di chiamarlo.
I due motori esistono, e sono buoni: `visionsuite.pm2d` ha il matcher e l'auto-tune, `visionsuite.dxf` ha
`DXFCompare`, l'`ICPAligner`, il ricampionamento, il controllo di tolleranza e il generatore di rapporto.
Ma sono **librerie**, non strumenti di grafo. Fra loro e TieMeasureFlow manca un ponte, e il ponte va
costruito **in VisionSuite**, non qui: la decisione 4 della spec di agosto — il server non importa mai
VisionSuite — e il criterio di accettazione della corsia A1 — *«`grep` non trova algoritmi di visione dentro
il repo di TieMeasureFlow»* — rendono illegittima qualunque scorciatoia che scriva visione dentro questo
repository.
🔑 **La conseguenza commerciale, detta chiara:** oggi TieMeasureFlow sa eseguire un grafo che misura
distanze, angoli, raggi e diametri su un'immagine. Le due cose che Menoncin ha chiesto per nome — il
confronto DXF, che il suo cliente finale non ha, e il pattern matching, che il loro «funziona male» — non
sono ancora raggiungibili da nessun percorso del prodotto.
## 4. Le altre lacune, in ordine di peso
1. **Nessuna interfaccia.** Nel frontend Flask la visione compare in due sole righe di
`templates/measure/task_list.html`, le etichette «Confronto profilo» e «Misura camera». Non esiste la
schermata del Maker per comporre un grafo, scegliere un'immagine di riferimento, agganciare un'uscita a
una quota o premere «prova»; non esiste la schermata dell'operatore che acquisisce e vede l'esito. Oggi
un grafo entra solo via `POST /api/tasks` scrivendo il JSON a mano.
2. **Nessun agente di stazione, nessun registro dei device.** La tabella `station_devices` della spec non
esiste in nessuna migrazione; il WebSocket `/api/stations/agent` non esiste; `vs-camera`, cioè il
pacchetto che contiene il driver Itala, non è montato. L'unico modo di far entrare un'immagine è un
upload HTTP.
3. **L'immagine di produzione non viene conservata.** `vision_results.image_path` e `overlay_path`
migrano e restano vuote: nessuna riga le scrive. Una misura contestata non ha prova materiale, e
l'overlay — l'immagine con sopra le zone dove il sistema ha guardato — non esiste.
4. **La prova di non regressione è promessa e non mantenuta.** `expected_json` esiste sulla tabella delle
immagini di riferimento; nessun percorso la scrive, `preview` compreso. È la rete di sicurezza che
rende sostenibile aggiornare il sottomodulo con il motore installato su due host: senza, ogni
aggiornamento di VisionSuite è un salto al buio.
5. **Il sottomodulo è indietro di 67 commit**, dal 15/08 al 10/09. Fra questi c'è almeno una correzione di
`vs-core` sulla ricomposizione dei mosaici. Aggiornare il pin non è manutenzione facoltativa: è la
decisione 2 della spec, che vuole server e stazione sullo stesso commit, e va fatta con la prova di non
regressione già in piedi — cioè dopo il punto 4, non prima.
6. **Le tre verifiche a monte non sono mai state fatte**, e la spec di agosto le poneva come condizione
d'ingresso: lo stato reale di VisionSuite rispetto al suo README, il test di calibrazione dato per
rosso (focale recuperata a 373 contro 456 attesi, tolleranza 5%) e il peso effettivo del sottoinsieme
di pacchetti che il worker installa. La seconda pesa più delle altre: **tutte le quote in millimetri
poggiano sulla calibrazione**, e finché quel test non è verde o spiegato, i millimetri che il sistema
produce non hanno garanzia.
## 5. Che cosa si vuole ottenere
Un operatore apre un task di visione su una postazione, appoggia il profilato sul piano, e il sistema:
- riconosce il pezzo e la sua posa (**pattern matching**);
- ne confronta il profilo con il **DXF di riferimento**, dichiarando lo scarto massimo e dove sta;
- ne ricava **due o tre quote lineari** (le quote massima e minima che il cliente chiede);
- applica le tolleranze della ricetta e dà **OK / warning / fail**, con il gate del fuori tolleranza e
l'autorizzazione del capoturno come per qualunque altra misura;
- registra tutto in `measurements` e `vision_results`, con immagine, overlay, versione del motore e
taratura in vigore.
Nulla di quanto il prodotto già fa — ricette, SPC, Cp/Cpk, carta di controllo, export, tracciabilità —
deve cambiare. Una quota da camera resta una misura come le altre: è la decisione 7, e va tenuta.
## 6. Il lavoro, in sei fasi
Le fasi sono ordinate per dipendenza. Le prime due sono il cammino critico verso la fiera; la terza è il
cammino critico verso l'installazione a Tràfilo. Non sono stimate in ore: una stima si scrive dopo aver
guardato il codice dei due motori, e quella lettura fa parte della fase V0.
### V0 — Le verifiche a monte, prima di scrivere righe
Le tre della spec di agosto (capitolo 4, punto 6), più una quarta che nasce da questo documento: leggere
`visionsuite.pm2d.matcher` e `visionsuite.dxf.compare` per sapere **che forma hanno gli ingressi e le
uscite** dei due motori, perché è quella forma che decide la firma degli strumenti della fase V1.
*Fatto quando:* il test di calibrazione è verde o il suo scarto è spiegato per iscritto; il peso
dell'ambiente del worker è un numero misurato; esiste una nota di due pagine con le firme reali dei due
motori.
### V1 — Il ponte — ✅ **fatta il 14/09/2026** (VisionSuite `6f5990c`, issue #2 chiusa)
**Il lavoro è stato fatto in VisionSuite**, come questo documento prescriveva. Gli strumenti non sono
finiti in `vs-task` insieme ai dieci geometrici, ma **ciascuno nel pacchetto del proprio motore** —
`visionsuite.pm2d.task_tools` e `visionsuite.dxf.task_tools` — perché altrimenti il livello-task avrebbe
ereditato numba, opencv e fastapi da `pm2d` e scipy ed ezdxf da `dxf`, e se li sarebbe portati dietro
ogni consumatore che voleva soltanto una distanza fra due punti. Si dichiarano con l'extra `task`.
- `MatchPattern` — ingressi: la scena dal contesto, il modello per chiave o per percorso, punteggio
minimo, intervallo di angolo e di scala. Uscite: `found`, `score`, `row`, `col`, `angle_deg`, `scale`,
`n_matches`.
- `CompareDXF` — ingressi: immagine, `dxf_path`, `px_per_mm` (o la calibrazione dal contesto),
`tolerance_mm`. Uscite: `max_deviation_mm`, `mean_deviation_mm`, `n_points`, `overlay`.
**Il vincolo che ha retto:** gli strumenti espongono numeri, non verdetti. `CompareResult.passed` esiste
e resta fuori dalle uscite, e un test lo sorveglia. La `tolerance_mm` serve **solo a colorare l'overlay**:
è un parametro di disegno, non una soglia di accettazione.
⚠️ **Due cose che questo documento dava per buone non hanno retto alla prova, ed è il motivo per cui una
specifica si verifica invece di eseguirla.**
1. **«`vs-dxf` si porta dietro PyTorch per una riga» era falso.** Torch è stato tolto in L2 insieme al
rilevatore DexiNed che non aveva mai girato, e il `pyproject.toml` di `vs-dxf` lo dice per esteso. La
nota veniva dalla scheda VisionSuite in AI-OS, che su quel punto era invecchiata. Il lavoro non
esisteva: il container della visione non triplica.
2. **La posa iniziale non serve, ed è misurato.** Questo documento dava per scontato che `CompareDXF`
dovesse partire allineato dalla posa di `MatchPattern`. Ruotando la scena e lasciando l'ICP senza
aiuto, sul rettangolo forato a 4 px/mm: **0°** → 0,285 mm, **5°** → 0,346, **20°** → 0,288, **45°** →
0,243. Converge da solo, e a 45° meglio che a zero. In più `DXFCompare._pose_to_transform` non ha
chiamanti né test e trasla le **righe** dove il nome dice colonne: l'ingresso non è stato esposto,
perché chi lo avesse collegato al match avrebbe ottenuto una traslazione di centinaia di pixel
credendo di aver fatto la cosa giusta. Un test parametrico su 0°, 20° e 45° tiene ferma la scelta.
⚠️⚠️ **E una trappola nuova, da sapere prima di comporre ricette: su un pezzo simmetrico l'angolo del
match è ambiguo.** Sulla scena non ruotata il rettangolo torna `angle_deg = -180` con punteggio 0,924,
perché a 180° è identico a sé stesso. `row` e `col` restano affidabili; l'angolo, su un pezzo senza un
riferimento che rompa la simmetria, non è una risposta. I profilati di Tràfilo sono esattamente il genere
di pezzo in cui questo si presenta.
*Verificato:* `tests/test_visione_nel_grafo.py` carica una ricetta JSON che contiene **entrambi** gli
strumenti con `Task.from_dict`, la esegue e ottiene le uscite attese. 21 test nuovi; **1242 verdi** su
tutto il monorepo.
**Quello che resta da fare qui, in TieMeasureFlow** (è ciò che la issue #8 della lista lavori chiedeva, e
non è cambiato): `vs-dxf` entra negli extra `vision` e `vision-worker` di `pyproject.toml`, il pin del
sottomodulo avanza a `6f5990c`, e il runner **importa i due moduli** `visionsuite.pm2d.task_tools` e
`visionsuite.dxf.task_tools` — senza quell'import il registro non li conosce e una ricetta che li nomina
non si carica. Nessuna riga di algoritmo entra in questo repository.
*Fatto quando:* un grafo JSON che contiene `MatchPattern` e `CompareDXF` viene eseguito da
`POST /api/vision/preview` su un'immagine caricata, e restituisce le uscite attese. Senza camera, senza
interfaccia. E `test_vision_boundary.py` continua a passare.
### V2 — L'authoring nel Maker — **in fiera** (decisa il 14/09)
La schermata che oggi non esiste. Si compone con un **editor visuale a nodi** (decisione A, chiusa il
14/09). Il Maker, alla scrivania:
- carica o sceglie un'**immagine di riferimento** per il task (l'API esiste già);
- compone il grafo collegando gli strumenti — l'immagine entra, e da lì scendono il riconoscimento, le
quote e il confronto col disegno;
- preme **prova** e vede le uscite (`/preview` esiste già);
- **aggancia** ciascuna uscita a una quota della ricetta, scrivendo `vision_output` sulla subtask (il
campo esiste già), e su quella quota mette nominale e tolleranze come su qualunque altra;
- **promuove le uscite della prova a `expected_json`**, che è ciò che accende la prova di non regressione
(lacuna 4) e chiude una promessa che la spec di agosto ha fatto e non ha mantenuto.
**Il taglio per la fiera** (deciso il 14/09): ci va tutto **fino alla misura vera** — comporre, provare,
agganciare alle quote, e il ciclo dell'operatore che misura e riceve OK/warning/fail con le tolleranze
applicate. È la storia che vende: il confronto DXF **dentro il flusso di produzione**, non un tool a sé.
Restano fuori dalla fiera, e si fanno dopo, la promozione a `expected_json` e la prova di non regressione:
valgono per la robustezza, ma allo stand non si mostrano.
⚠️ **Un limite dell'editor, emerso il 14/09 costruendo gli strumenti.** Negli strumenti geometrici di
`vs-task` gli ingressi di scansione — `start_row`, `start_col`, `end_row`, `end_col` di `DetectLine` e
compagnia — **non sono collegabili**: si impostano a mano. Finché restano così, la posa che
`MatchPattern` trova non può spostare le scansioni delle quote, e il pezzo va appoggiato dove la ricetta
si aspetta. Renderli collegabili è un lavoro in VisionSuite, piccolo ma non nullo, ed è **il passo che
rende utile il riconoscimento dentro una ricetta di misura**: senza, `MatchPattern` serve a dire se il
pezzo c'è ed è quello giusto, non a inseguirlo.
*Fatto quando:* una ricetta con un task di visione si compone interamente dall'interfaccia, senza scrivere
JSON a mano, e un operatore la esegue ottenendo un esito vero. La prova di non regressione — rieseguire
l'intero parco ricette su immagini di riferimento e vedere quali quote si sono mosse — è il criterio della
parte differita.
### V3 — L'agente di stazione, i device, la camera
È il cammino critico per Tràfilo, perché è ciò che rende il sistema utilizzabile in reparto.
- Tabella `station_devices` e migrazione, così com'è descritta nella spec di agosto (specchio in sola
lettura, popolato dall'agente; un device che non si dichiara più è **offline**, non cancellato).
- WebSocket `/api/stations/agent`: **è l'agente che apre la connessione**, il server non bussa mai a un PC
di reparto. È la scelta che evita di aprire porte in fabbrica.
- L'agente: sottile, monta `vs-core` e `vs-camera` e basta; acquisisce e carica il fotogramma. Camera
**Itala** di Opto Engineering, il cui producer GenTL è già stato provato in VisionSuite e ha richiesto
mezz'ora invece delle sei-dieci ore stimate.
- `vision_results.image_path` e `overlay_path` finalmente scritte (lacuna 3), con l'overlay prodotto dal
worker.
*Fatto quando:* un operatore misura un pezzo da una postazione vera, e la riga in `vision_results` porta
immagine, overlay, device, taratura e versione del motore.
### V4 — L'esecuzione sulla stazione
La policy per stazione, il ripiego in locale quando il server non risponde, e il **rifiuto di misurare**
quando la versione del motore non concorda con quella del server. Quest'ultimo non è un dettaglio di
build: è la garanzia metrologica su cui poggia tutto il resto.
*Fatto quando:* una stazione con un pin discorde si rifiuta di misurare e lo dichiara, invece di produrre
numeri plausibili.
### V5 — Il task `dxf_compare` come tipo a sé
Il valore `dxf_compare` esiste già nell'enum. Qui prende il suo comportamento: quote sintetiche derivate
dal confronto (scarto massimo, numero di zone fuori tolleranza) trattate come misure normali, e il
**rapporto allegato** che `visionsuite.dxf.report` sa già generare, archiviato accanto all'immagine.
## 7. Le decisioni che restano da prendere
Nessuna di queste si può dedurre dal codice: vanno chiuse da Adriano, alcune con il cliente.
**A. Come si compone un grafo — chiusa il 14/09/2026: un editor visuale a nodi.** Adriano lo ha rimesso
nel perimetro, e la scelta **revoca la revoca del 09/08**. Allora TieWorkflow era uscito perché *«un editor
ha senso solo se a comporre le pipeline è qualcuno che non programma, e il cliente aveva chiesto misure,
non un editor»*: motivazione giusta quando il committente era Tràfilo, che vuole misurare profilati.
Adesso il modulo è un investimento per prendere lavori futuri (decisione F), e il pubblico dello stand non
è l'operatore di Tràfilo — è chi allo stand deve immaginarci il **proprio** pezzo, e a quello un form fisso
non parla.
Le altre due strade restano scritte qui perché sono la via d'uscita, non perché siano state scelte: un
**modulo per tipo di task** con i parametri in chiaro (il minimo che funziona) e una **libreria di grafi
preconfezionati** che il Maker sceglie e tara.
⚠️⚠️ **È la più cara delle tre, su un cammino critico già pieno.** Ad agosto l'editor era stimato **12-18
ore su un perimetro più piccolo** — senza `MatchPattern`, senza `CompareDXF`, senza l'aggancio quota→uscita
— ed è l'ordine di grandezza che aveva fatto sfondare il budget del piano ferie. Quelle ore ora si sommano
alla V1, anch'essa obbligatoria per la fiera, dentro 43 giorni. **Il ripiego va deciso adesso, non il 20
ottobre:** se a metà ottobre l'editor non regge, si espone il modulo per tipo di task, che mostra le stesse
misure con meno scena.
**B. La scala: pixel o millimetri.** Il 09/08 si era deciso che le quote potessero restare **in pixel**, e
che per il DXF compare servisse un fattore di scala ricavato da una quota nota misurata col calibro. Va
confermato: sono due prodotti diversi da spiegare al cliente, e il secondo dipende dal test di
calibrazione della fase V0.
**C. Il dimensionamento del server di Tràfilo.** Con l'esecuzione lato server come default, il pattern
matching contende la CPU fra tutte le stazioni. La domanda alla D-4 non è più solo quale macchina e quanto
disco: è anche quanti core e quanta RAM. Venti postazioni non sono tre.
**D. La conservazione delle immagini.** Quelle di riferimento sono poche e vivono quanto la ricetta;
quelle di produzione sono una per pezzo e crescono senza limite. Serve una scadenza, o una regola del tipo
«si conservano solo quelle dei pezzi fuori tolleranza». Senza, il disco del cliente si riempie e nessuno
lo ha avvertito.
**E. Che cosa fa la stazione se la rete cade** e non è configurata per elaborare in locale: si blocca, o
consente il calibro segnando il task come non eseguibile? È una domanda per Tràfilo.
**F. Chi paga — chiusa il 14/09/2026.** Nessuno, ed è una scelta. Il modulo di visione è un
**investimento commerciale di Tielogic**: non si quota a Ricerca e Misure, non entra nel setup né nel
canone dell'OFF-2026-022, e non aspetta un'offerta per cominciare. Serve a prendere lavori futuri, e il
posto dove quell'investimento si trasforma in lavori è la fiera.
Ne discendono tre conseguenze che questo documento adotta:
1. **Il modulo non ha una data di consegna contrattuale**, quindi l'unica scadenza che lo governa è la
fiera del 27-29 ottobre. La fase V1 è l'unica del percorso visione che ha una data vera.
2. **Tràfilo va servito con il perimetro venduto** — le dodici modifiche del 28/07, l'installazione
on-premise, l'agente di stazione — e quel lavoro non si rallenta per la visione: è quello che è già
stato fatturato. Dove la visione e Tràfilo si contendono le stesse ore, vince ciò che è stato venduto,
con l'unica eccezione della V1 prima della fiera.
3. **Il confine va tenuto per iscritto**: le ore spese per la fiera sono a carico Tielogic, quelle spese
per Tràfilo stanno nella commessa. Senza quella riga, a consuntivo il modulo di visione sembrerà un
costo della commessa Ricerca e Misure e ne falserà il margine.
⚠️ Resta aperto un pezzo che non è «chi paga» ma «che cosa»: **cosa si mostra in fiera** non coincide con
cosa si consegna. Una demo può girare su un grafo preparato, un pezzo noto e una posa controllata; un
prodotto no. Deciderlo prima cambia l'ampiezza della V1.
## 8. Quello che non va fatto
- **Non scrivere algoritmi di visione in questo repository.** Il confine è sorvegliato da un test; se
domani il server importasse VisionSuite per una scorciatoia, il vantaggio si perderebbe in silenzio.
- **Non rifare l'interfaccia** oltre le schermate nuove che le fasi V2 e V3 richiedono.
- **Non toccare SPC, ricette e integrazione GAIA.** GAIA è il gestionale del cliente finale e non è in
questo perimetro.
- **Non aggiungere il modulo AI** di cui si è parlato in call: è perimetro nuovo, senza offerta.
- **Non far giudicare il grafo.** I verdetti di `vs-dxf` e di `vs-measure` restano inutilizzati: decide
`measurement_service`.
- **Non avanzare il pin del sottomodulo senza la prova di non regressione in piedi.** Con il motore
installato su due host, un aggiornamento non verificato sposta i numeri senza che nessuno se ne accorga
fino alla contestazione.
## 9. Il calendario, per capire le priorità
La catena è **Tielogic produce → Ricerca e Misure (Menoncin) rivende → Tràfilo usa**, venti postazioni.
Due scadenze esterne, e dal 14/09 non pesano più allo stesso modo.
- **Fiera del 27-29 ottobre**, banchetto Tielogic nella zona Opto Engineering. Le servono **V1 e V2
insieme**, perché allo stand si mostra il prodotto e non una demo: comporre una ricetta di visione
dall'interfaccia, misurare un pezzo, vedere l'esito. È l'unica data del percorso visione, ed è il posto
dove l'investimento della decisione F si trasforma in lavori.
- **Installazione a Tràfilo**, attesa per settembre. Le serve la **V3**, ed è **ciò che è stato
fatturato**. ⚠️ Con V1 e V2 entrambe prima della fiera, la V3 resta indietro: è una scelta consapevole,
ma la tempistica dell'installazione era stata data a Menoncin, quindi va **detta a lui** invece di
scoperta da lui.
⚠️ Il rischio non è che manchi una delle due: è che l'editor a nodi si prenda le ore della V1. Il ripiego
è scritto nella decisione A, e va guardato a **metà ottobre**, quando cambiare strada costa ancora poco.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,474 @@
# Integrazione di VisionSuite in TieMeasureFlow — design
> Progetto: TieMeasureFlow V3.0.0 → V3.1.0
> Data: 2026-08-16
> Stato: design approvato, piano di implementazione da scrivere
## Perché
TieMeasureFlow oggi registra misure prese a mano, con il calibro USB o con il
tastierino. Il passo successivo che il cliente chiede è misurare con una camera:
acquisire l'immagine di un pezzo e ricavarne le quote, oppure confrontarne il
profilo con il disegno DXF di riferimento.
La visione artificiale non va scritta qui. Tielogic ha già
[VisionSuite](ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git), un
monorepo di moduli riusabili il cui README indica esplicitamente TieMeasureFlow
fra i consumatori previsti. Questo documento descrive come agganciarlo.
Il terreno è in parte già preparato. La migrazione `007_task_type` ha introdotto
il tipo di task dichiarato e l'enum contiene fin da allora due valori che nessuno
produce né consuma ancora:
```python
TASK_TYPES = ("note", "measure", "drawing", "xf_compare", "camera_measure")
MEASURING_TASK_TYPES = ("measure", "camera_measure")
```
Questo lavoro li riempie.
## Che cosa entra e che cosa resta fuori
**Entra:** l'acquisizione da camera, l'esecuzione di un programma di visione su
quell'immagine — pattern matching più misure, oppure pattern matching più
confronto DXF — e l'ingresso dei risultati nel modello di misura che già esiste.
**Resta fuori, per ora:** i semafori USB. Sono previsti dall'architettura — è
l'agente di stazione a possederli, insieme alle camere — ma non fanno parte di
questa consegna. Il confine è disegnato perché ci entrino senza ristrutturazioni.
## Le otto decisioni
Sono le scelte prese in fase di brainstorming, con il motivo per cui sono state
prese. Chi legge fra sei mesi ha bisogno del motivo più della scelta.
### 1. La visione gira sia sul server sia sulla stazione
Il motore è un componente unico distribuito in due posti: accanto al server e
dentro l'agente di stazione. Questo dà la resilienza — se cade il collegamento,
la stazione continua a misurare — e permette al server di elaborare in fase di
produzione, di comporre ricette per conto del Maker e di rifare analisi su
immagini archiviate.
È coerente con la decisione **D-3**, chiusa il 28/07: dati sul server, app di
stazione su ogni PC.
### 2. Server e stazione montano la stessa identica versione di VisionSuite
Non è una raccomandazione di build: è un vincolo metrologico. Se il server monta
un commit e una stazione ne monta un altro, lo stesso pezzo con lo stesso grafo
può dare due numeri diversi, e nessuno se ne accorge finché non arriva una
contestazione.
Da qui tre obblighi che il design deve garantire:
- VisionSuite entra come **sottomodulo git su un commit preciso**, lo stesso per
entrambi — la forma che il README di VisionSuite prescrive
- ogni misura porta scritto **con quale versione del motore** è stata prodotta
- una stazione che monta una versione diversa da quella del server **si rifiuta
di misurare** e lo dichiara, invece di produrre numeri plausibili e sbagliati
### 3. L'agente di stazione è sottile
L'agente parla ai device, consegna immagini e — più avanti — piloterà i semafori.
Non contiene decisioni di visione.
La conseguenza pratica è l'impacchettamento: un agente che non contiene
VisionSuite non contiene nemmeno PyTorch. Distribuirlo come eseguibile su Windows
o come container su Linux diventa realistico, e la scelta su driver e formato può
restare tardiva — che è bene, perché dipende dalla **D-4**, ancora aperta.
L'agente incorpora il motore solo quando la stazione è configurata per elaborare
in locale, e in quel caso resta comunque un guscio: riceve un grafo, lo esegue,
restituisce le uscite.
### 4. Il server non importa mai VisionSuite
La visione sul lato server vive in un **servizio separato** nello stesso compose,
il *vision worker*. Il server FastAPI continua a fare quello che fa —
autenticazione, ricette, misure, statistica — e resta leggero.
Quattro ragioni concrete: l'immagine dell'API non passa da poche centinaia di
megabyte a cinque gigabyte; un aggiornamento di VisionSuite non richiede di
riavviare l'API in produzione; un'elaborazione che va in crash non porta giù le
richieste degli altri tablet; il giorno in cui la visione chiede una GPU o una
macchina propria si sposta senza toccare il resto.
Il confine va difeso: se un domani il server importasse VisionSuite per una
scorciatoia, il vantaggio si perde in silenzio.
### 5. La configurazione dei device sta sul PC
Le camere — e domani i semafori — appartengono alla macchina, non alla ricetta.
Il server ne tiene uno **specchio** in sola lettura, popolato dall'agente quando
si connette, perché il Maker deve poter scegliere un device da un elenco stando
alla scrivania.
Un device che l'agente non dichiara più è **offline**, non cancellato: una
ricetta che lo referenzia deve continuare a esistere e a spiegare perché non può
girare.
### 6. Dove si esegue lo decide la configurazione della stazione
Ogni PC dichiara se elabora in locale o delega al server, nella stessa
configurazione dove già stanno i device. Il default è il server.
Le macchine capaci si prendono il carico e non mandano immagini in rete; quelle
deboli delegano. Se il server non risponde e la stazione ha il motore, la
stazione ripiega in locale e lo segna nel risultato. Un impianto misto si governa
senza toccare le ricette.
La ricetta non dice mai dove gira: legare una scelta di impianto a un documento
di produzione significa dover rimettere mano alle ricette per spostare il carico.
### 7. Il verdetto lo dà TieMeasureFlow, non il grafo
Gli strumenti di quota di `vs-task` hanno anche loro `nominal`, `plus`, `minus` e
un `outcome`. Quell'esito viene ignorato e quei limiti restano vuoti: il grafo
produce numeri, `measurement_service` decide se sono buoni.
Due giudici sullo stesso valore è il modo più rapido per ottenere un CSV che si
contraddice e una contestazione che non si sa come chiudere.
La conseguenza è che tutto il resto continua a funzionare senza modifiche:
pass/warning/fail, gate del fuori tolleranza con autorizzazione del capoturno,
Cp/Cpk, carta di controllo, colonne del CSV di produzione. Una quota da camera è
una misura come le altre.
### 8. La sorgente immagine è un'astrazione a tre facce
Il motore riceve un'immagine e non sa da dove venga. Le tre sorgenti sono: dal
vivo da un device tramite l'agente, un'immagine di riferimento salvata sul
server, un file caricato dal Maker.
Non è un'astrazione nostra: è quella che VisionSuite si è già data — *«espongono
una sorgente, non una telecamera: è questo che permette di collaudare un impianto
a cinque telecamere senza avere le cinque telecamere sul tavolo, e di scrivere
prove che danno sempre lo stesso risultato»*.
Sblocca tre cose che altrimenti non ci sarebbero:
- il Maker compone alla scrivania, senza tenere occupata una stazione né avere il
pezzo in mano
- una ricetta si prova prima di andare in produzione, invece di scoprirne i
difetti misurando
- diventa possibile una **prova di non regressione**: salvando accanto
all'immagine il risultato atteso, si riesegue l'intero parco ricette dopo un
aggiornamento di VisionSuite e si vede quali quote si sono mosse
Quest'ultima è la rete di sicurezza che rende sostenibile aggiornare il
sottomodulo, con un motore che sta su due host.
> **Non ancora consegnata.** La colonna `expected_json` esiste, ma nessun
> percorso la scrive — nemmeno `preview`, che è l'unico posto dove un grafo gira
> davvero su un'immagine. La prova di non regressione è quindi **promessa da
> questo documento e non mantenuta da nessuna riga di codice**. Diventa una
> consegna nominata del piano 1b: «`preview` può promuovere le proprie uscite a
> `expected_json`». Finché non è fatta, questa decisione non va dichiarata
> completa.
## Architettura
### I componenti
| Componente | Nuovo | Dove vive | Responsabilità |
|---|---|---|---|
| `vendor/visionsuite` | sì | sottomodulo git | i pacchetti `vs-core`, `vs-camera`, `vs-pm2d`, `vs-measure`, `vs-dxf`, su un commit fisso |
| Vision runner | sì | libreria condivisa | immagine + grafo + calibrazione → quote e artefatti. Unica implementazione, due host |
| Vision worker | sì | container nel compose | espone il runner dietro una API interna. Il container pesante |
| Agente di stazione | sì | PC di reparto | possiede i device, acquisisce, apre la connessione verso il server, incorpora il runner se configurato |
| Backend FastAPI | modificato | container esistente | tipi di task, grafo sul task, registro device, inoltro, ingresso risultati. **Nessun import di VisionSuite** |
| Frontend Flask | modificato | container esistente | Maker: device, fotogramma, aggancio quote. Measure: schermata del task con camera |
L'aggancio del sottomodulo segue la forma prescritta dal README di VisionSuite:
dipendenza dai soli pacchetti che servono, dichiarati in path editable.
```toml
[tool.uv.sources]
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true }
```
I due host non montano lo stesso insieme, ed è il punto dell'intera struttura di
VisionSuite:
| Pacchetto | Worker | Agente | Perché |
|---|---|---|---|
| `vs-core` | sì | sì | strutture di base e calibrazione, servono a entrambi |
| `vs-pm2d` | sì | solo se elabora in locale | pattern matching |
| `vs-measure` | sì | solo se elabora in locale | primitive e quote |
| `vs-dxf` | sì | solo se elabora in locale | confronto col disegno. È quello che porta PyTorch |
| `vs-camera` | **no** | sì | il worker non tocca hardware: riceve immagini già acquisite |
Un agente su una stazione che delega al server monta quindi due soli pacchetti,
`vs-core` e `vs-camera`, e resta leggero. È la differenza fra un eseguibile
distribuibile e un'installazione da cinque gigabyte su ogni PC di reparto.
I nomi di importazione non sono uniformi — `vs-core` si importa come
`visionsuite`, `vs-pm2d` come `pm2d`, `vs-dxf` come `dxf_compare` — ed è una
stortura nota e dichiarata a monte, non un errore da correggere qui.
### Il flusso di una misura, esecuzione sul server
```
operatore apre il task
→ client chiede al server di eseguire
→ server chiede all'agente un fotogramma sul device dichiarato
→ agente acquisisce e carica l'immagine
→ server passa immagine e grafo al worker
→ worker restituisce le uscite
→ server applica pass/fail, gate del fuori tolleranza, salva in measurements
→ client mostra esito e immagine con le zone dove ha guardato
```
### Il flusso con esecuzione in locale
Il giro si accorcia: il server manda il grafo all'agente, l'agente acquisisce ed
esegue, e rimanda uscite più immagine. **Da lì in poi il server fa le stesse
identiche cose.** Il verdetto, il gate e il salvataggio non cambiano mai posto:
cambia chi calcola i numeri, non chi decide se sono buoni.
È questo che rende le due strade equivalenti a valle, e che permette di cambiare
la configurazione di una stazione senza toccare né il frontend né le ricette.
## Modello dati
### Il grafo di visione
`recipe_tasks` prende **`vision_json`** (JSON, nullable): il grafo di `vs-task`
serializzato, opaco al server.
Il versionamento arriva gratis. Il copy-on-write delle ricette copia già i task
in profondità, quindi una modifica al grafo su una ricetta con misure produce una
versione nuova come qualunque altra modifica; in `recipe_service` è un campo in
più nella copia, non una logica nuova.
Non si riusa `annotations_json`, che sta lì accanto: le annotazioni sono un
disegno per l'operatore, il grafo è un programma da eseguire. Hanno cicli di vita
e regole di validazione diversi, e mescolarli significa non poter validare né
l'uno né l'altro.
### Il legame fra quota del grafo e quota della ricetta
`recipe_subtasks` prende **`vision_output`** (String, nullable): la coppia
«identificativo dello strumento, nome della sua uscita».
È la stessa forma di legame che `vs-task` usa internamente, e per la stessa
ragione dichiarata nel suo README: *«Il legame è un ID, non un puntatore»* — un
riferimento in memoria non si scrive su disco.
Tutto il resto della subtask — nominale, UTL, UWL, LWL, LTL, unità — resta com'è
e continua a significare quello che significa oggi.
### Le misure
`input_method` passa da `("usb_caliper", "manual")` a
`("usb_caliper", "manual", "camera")`.
Il contorno della visione **non** va su `measurements`. Quella tabella la leggono
la statistica e l'export a ogni giro, e appesantirla la rovina. Ma soprattutto
sarebbe sbagliato di modello: una sola acquisizione produce N quote, e immagine,
overlay, device e versione del motore sono gli stessi per tutte.
Nasce quindi **`vision_results`**, una riga per **esecuzione**:
| Campo | Perché c'è |
|---|---|
| `image_path`, `overlay_path` | l'immagine, e quella con sopra le zone dove ha guardato |
| `engine_version` | il commit di VisionSuite che ha prodotto i numeri |
| `executed_on` | `server` o `station` |
| `station_id`, `device_id` | da quale macchina e quale camera |
| `calibration_snapshot` | la taratura in vigore in quel momento, **copiata** |
| `graph_snapshot` | il grafo effettivamente eseguito, **copiato** |
| `duration_ms`, `executed_at` | quanto è costata, quando |
Su `measurements` una sola colonna nuova: `vision_result_id`, FK nullable.
Calibrazione e grafo si copiano invece di essere referenziati per la stessa
ragione: una taratura rifatta sei mesi dopo non deve riscrivere il significato di
una misura già presa.
### Il registro dei device
**`station_devices`**: `station_id`, `code` (l'identificativo locale), `kind`
(`camera` oggi, `light` domani), `label`, `capabilities` (JSON), `calibration`
(JSON) con `calibration_taken_at`, `last_seen_at`.
Si popola dall'agente. Il server lo riceve e non lo modifica.
### Le immagini di riferimento
**`vision_reference_images`**: `task_id`, `path`, e la provenienza per intero —
`station_id`, `device_id`, snapshot della calibrazione, `engine_version`,
`acquired_at`, una nota — più `expected_json` per l'atteso della prova di non
regressione.
La provenienza non è rimandabile. Senza, si riesegue il grafo mesi dopo, escono
numeri diversi, e non c'è modo di distinguere un motore aggiornato da un'ottica
spostata.
### Migrazione 011
Cinque cose: `vision_json` su `recipe_tasks`, `vision_output` su
`recipe_subtasks`, `camera` in `input_method_enum`, `vision_result_id` su
`measurements`, e le tre tabelle nuove.
Più una sesta, **da fare adesso o mai**: `task_type_enum` contiene `xf_compare`,
refuso per `dxf_compare`. Oggi nessuna riga usa quel valore e correggerlo costa
una riga. Dal primo task salvato in poi diventa una migrazione di dati, e il
refuso finisce nelle API pubbliche.
Come le migrazioni 008 e 010, va scritta in *batch mode* per la compatibilità
SQLite dei test e verificata in resa MySQL con `alembic upgrade X:Y --sql` prima
del deploy: è la procedura che il progetto si è già dato.
## API
L'agente apre lui la connessione. **Il server non bussa mai a un PC**, e questo
evita di dover aprire porte sulle macchine di reparto — che è materia della D-4.
| Verso | Endpoint | Cosa fa |
|---|---|---|
| agente → server | `WS /api/stations/agent` | si autentica con la chiave di stazione, dichiara i device, resta in ascolto |
| server → agente | comando `acquire` | «scatta sul device X»; l'agente carica con `POST /api/vision/frames` |
| server → agente | comando `execute` | esecuzione in locale: manda il grafo, riceve le uscite |
| client → server | `POST /api/vision/execute` | esegui il task per la produzione in corso; **il server sceglie dove** |
| client → server | `POST /api/vision/preview` | authoring: esegui su una sorgente qualsiasi, non salvare nulla |
| client → server | `GET /api/stations/{code}/devices` | l'elenco per il Maker |
| client → server | `POST /api/vision/reference-images` | salva un fotogramma come riferimento |
| server → worker | `POST /run` | interno, mai esposto: immagine e grafo → uscite e overlay |
Un solo endpoint decide dove si esegue, ed è `execute`. Il client non sa e non
deve sapere se ha misurato il server o la stazione: chiede una misura e riceve un
esito.
## Errori e casi limite
| Caso | Comportamento |
|---|---|
| Versione del motore diversa fra stazione e server | la stazione si rifiuta di misurare e lo dichiara. Mai produrre numeri con un motore non concorde |
| Agente non raggiungibile | il task di visione non è eseguibile e lo dice. Da decidere con il cliente se la stazione debba consentire il ripiego sul calibro |
| Server non raggiungibile, stazione con motore | la stazione esegue in locale e lo segna in `executed_on` |
| Device dichiarato dalla ricetta ma offline | la ricetta resta valida, il task spiega perché non può girare |
| Il grafo non produce un'uscita attesa da una subtask | la misura non si salva, l'errore nomina l'uscita mancante |
| Elaborazione in crash o oltre il tempo | il worker isola il guasto, l'API resta in piedi, il task riporta l'errore |
| Device senza calibrazione | le quote in millimetri non si producono. Mai convertire con un fattore implicito |
## Prove
Ai tre file di test che il progetto ha già e che leggono i sorgenti invece di
renderizzare — `test_offline.py`, `test_layout_shell.py`,
`test_template_js_syntax.py` — se ne aggiunge la stessa specie:
- **una prova che il server non importi VisionSuite.** È il confine su cui poggia
la decisione 4, e senza qualcuno che lo controlli si perde in silenzio
- **una prova che il commit del sottomodulo dichiarato coincida** con quello che
server e agente montano
Per il resto, il runner si prova su immagini di riferimento salvate — che è
precisamente ciò per cui l'astrazione della sorgente esiste. Nessuna prova
richiede una camera collegata.
## Quello che questo design non risolve
- **Il dimensionamento del server.** Il pattern matching è pesante e, con
l'esecuzione lato server come default, la CPU è contesa fra tutte le stazioni.
Va aggiunto alla **D-4**, che finora chiedeva solo quale macchina e quanto
disco: ora chiede anche quanti core e quanta RAM.
- **La conservazione delle immagini.** Due politiche distinte, entrambe da
portare in D-4: le immagini di riferimento sono poche e vivono quanto la
ricetta; quelle di produzione sono una per pezzo e crescono senza limite, e
vogliono una scadenza o una regola del tipo «si conservano solo quelle dei
pezzi fuori tolleranza».
- **Che cosa fa la stazione se la rete cade** e non è configurata per elaborare
in locale: si blocca, o consente il calibro e segna il task come non
eseguibile? È una domanda per il cliente.
- **L'impacchettamento dell'agente.** Le macchine possono essere Linux o Windows
e i driver possono stare sulla macchina o nel container. La decisione è
volutamente tardiva; il design la rende tale tenendo l'agente sottile e neutro.
## Correzioni imposte dall'esecuzione
Il piano derivato da questo documento è stato eseguito il 16/08/2026. Sei cose
che qui erano scritte male o taciute, corrette dai fatti.
### Il gate del fuori tolleranza vale per l'acquisizione, non per la quota
La decisione 7 e il testo del punto 5 parlano di «la quota successiva». Quella
regola era stata pensata per la misura **manuale e sequenziale**, dove esiste
davvero un momento successivo in cui l'operatore potrebbe tirare avanti con una
lettura cattiva irrisolta.
Una camera produce tutte le N quote **nello stesso istante**. Dentro
un'acquisizione non esiste un «tirare avanti», quindi lì non c'è nulla da
presidiare — e applicare la regola sequenziale a un evento simultaneo produceva
un blocco permanente: la lettura fuori tolleranza non veniva mai registrata,
quindi nessuno poteva autorizzarla, e rifare la stessa foto ripeteva l'errore.
Il gate si valuta **una volta per acquisizione**: se la misura pendente è fra le
quote che questa acquisizione riprodurrà non blocca — riacquisire *è* rimisurare
quelle quote, che questo documento già dichiara la via d'uscita che non richiede
il capoturno. Fra due acquisizioni, e alla chiusura del ciclo, blocca come prima.
### Il repository è deliberatamente bi-ambiente
`vs-task`, `vs-measure` e `vs-pm2d` dichiarano `requires-python >= 3.13`;
`vs-core` si ferma a 3.10. Backend e frontend restano su **3.11**, l'albero della
visione gira su **3.13**, e l'extra `vision` porta i marker d'ambiente che
tengono separate le due risoluzioni.
Questo rafforza la decisione 4 più di come era argomentata: con VisionSuite dentro
il server FastAPI, questa scoperta avrebbe costretto l'intero backend a 3.13.
### La versione del motore si timbra al build
`vendor/visionsuite/.git` è un file che rimanda a `../../.git/modules/...`, che
nel contesto di build non esiste: interrogare git dentro il container non
funziona. `engine_version()` legge `VISION_ENGINE_VERSION` dall'ambiente, ripiega
su git nei checkout di sviluppo, e **alza un errore** se non risolve né l'uno né
l'altro. Il build fallisce se la variabile manca.
### La divisione degli extra compra esplicitezza, non peso
Gli extra sono due — `vision` per il runner, `vision-worker` per il servizio web
— ed è la forma giusta. Ma la motivazione che sembrava ovvia è falsa: `vs-pm2d`
dichiara **di suo** `fastapi`, `uvicorn`, `python-multipart` e `pillow`, quindi
un agente di stazione che fa pattern matching se li porterà dietro comunque. La
correzione vera è a monte — una libreria non dovrebbe dipendere da un server web
— e va aggiunta alle verifiche su VisionSuite qui sotto.
### L'immagine di produzione non viene ancora conservata
`vision_results.image_path` e `overlay_path` migrano e restano `NULL`: nulla le
scrive. Il flusso descritto in questo documento si chiude con «il client mostra
esito e immagine», e finché quelle colonne sono vuote una misura contestata non
ha prova materiale. Consegna nominata del piano 1b, insieme all'overlay.
### Prima di applicare le migrazioni in produzione
La 011 rinomina un valore di enum su una tabella in esercizio. Va contato prima
che nessuna riga usi `xf_compare`: in strict mode una riga sopravvissuta fa
fallire l'`ALTER` a metà deploy, fuori strict mode diventa `''` in silenzio.
Attenzione anche alla finestra: il `MODIFY` su `recipe_tasks` è una rinomina di
valore e userà `ALGORITHM=COPY`, mentre quello su `measurements.input_method`
aggiunge in coda e dovrebbe restare `INPLACE` — da confermare sulle dimensioni
reali, perché `measurements` è la tabella grossa.
## Da verificare prima di cominciare
VisionSuite è stato lavorato fino al 15/08/2026 e il suo ultimo commit è
*«Rassegna delle firme pubbliche prima che si congelino»*: le API si stanno
stabilizzando proprio ora. Prima di fissare il commit del sottomodulo:
- **il suo README è indietro rispetto al codice.** Dichiara `vs-measure` «da
scrivere» quando invece esiste, con circa 4.865 righe e tredici file di test, e
dichiara `vs-dxf` non avviabile quando l'import di `common.calibration` non c'è
più. Va riletto lo stato reale, non quello documentato.
- **il test di calibrazione dato per rosso** — la focale recuperata a 373 contro
456 attesi, con tolleranza al 5% — va verificato sullo stato attuale. Il README
di VisionSuite avverte di guardarlo *prima* di costruirci sopra misure in
millimetri, che è esattamente ciò che questo lavoro fa. Le calibrazioni sono
state toccate il 13/08 e lo stato potrebbe essere cambiato.
- **il peso dell'ambiente.** `vs-dxf` dipende da PyTorch e l'installazione
completa arriva a circa 5 GB. È la ragione per cui il worker è un container a
parte, e va confermato che il solo sottoinsieme necessario pesi meno.
+8 -9
View File
@@ -34,13 +34,6 @@ server {
proxy_read_timeout 120s; proxy_read_timeout 120s;
} }
# Upload files served by server
location /uploads/ {
proxy_pass http://server_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# All other requests → Flask client # All other requests → Flask client
location / { location / {
proxy_pass http://client_frontend; proxy_pass http://client_frontend;
@@ -50,12 +43,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Static file caching # Static file caching. Limitata a /static/: un add_header in questo
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ { # location spegne l'ereditarietà di quelli del blocco server (comportamento
# nginx), quindi vanno ripetuti qui.
location ~* ^/static/.*\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
proxy_pass http://client_frontend; proxy_pass http://client_frontend;
proxy_set_header Host $host; proxy_set_header Host $host;
expires 7d; expires 7d;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
} }
# SSL configuration (uncomment when using Let's Encrypt) # SSL configuration (uncomment when using Let's Encrypt)
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+81
View File
@@ -0,0 +1,81 @@
; Installer di TieMeasureFlow (TieMeasureFlow#76).
; Cosa fa: copia la cartella standalone di Nuitka in Program Files\Tielogic\TieMeasureFlow,
; crea in ProgramData le cartelle scrivibili da tutti gli utenti (licenze, uploads, camera, log)
; e i collegamenti in menu Start e sul desktop.
; Come si usa: lo lancia build.ps1 con /DVersione /DVersione4 /DOrigine /DUscita; a mano:
; ISCC /DVersione=3.0.0 /DVersione4=3.0.0.0 /DOrigine=build\nuitka\avvio.dist /DUscita=dist TieMeasureFlow.iss
; Setup silenzioso: TieMeasureFlow-setup-X.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
; (/CURRENTUSER per installare senza amministratore, in %LOCALAPPDATA%\Programs).
; ATTENZIONE: la licenza NON e' nel setup: va copiata in {commonappdata}\Tielogic\licenze\tiemeasureflow.json
; (la cartella e' scrivibile dagli utenti). La disinstallazione lascia licenze, database (tmf.db),
; uploads e config.env: sono dati del cliente.
#ifndef Versione
#define Versione "0.0.0"
#endif
#ifndef Versione4
#define Versione4 "0.0.0.0"
#endif
#ifndef Origine
#define Origine "..\..\build\nuitka\avvio.dist"
#endif
#ifndef Uscita
#define Uscita "..\..\dist"
#endif
[Setup]
; AppId proprio di TieMeasureFlow (diverso da quello di TieVision Studio)
AppId={{B7D4E2A1-5C93-4F6E-8A0B-3D1F7E9C2A64}
AppName=TieMeasureFlow
AppVersion={#Versione}
AppVerName=TieMeasureFlow {#Versione}
AppPublisher=Tielogic
DefaultDirName={autopf}\Tielogic\TieMeasureFlow
DefaultGroupName=Tielogic\TieMeasureFlow
PrivilegesRequired=admin
; /CURRENTUSER solo da riga di comando: serve alle prove senza amministratore
PrivilegesRequiredOverridesAllowed=commandline
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir={#Uscita}
OutputBaseFilename=TieMeasureFlow-setup-{#Versione}
SetupIconFile=TieMeasureFlow.ico
UninstallDisplayIcon={app}\TieMeasureFlow.exe
VersionInfoVersion={#Versione4}
VersionInfoCompany=Tielogic
VersionInfoProductName=TieMeasureFlow
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
DisableProgramGroupPage=yes
; il programma in esecuzione tiene aperti i file: Inno chiede di chiuderlo (in silenzioso lo chiude)
CloseApplications=yes
[Languages]
Name: "italiano"; MessagesFile: "compiler:Languages\Italian.isl"
[Tasks]
Name: "desktopicon"; Description: "Crea un collegamento sul desktop"; GroupDescription: "Collegamenti:"
[Dirs]
; scrivibili da tutti gli utenti: l'exe gira con l'utente che lo lancia, non con admin
Name: "{commonappdata}\Tielogic\licenze"; Permissions: users-modify
Name: "{commonappdata}\Tielogic\TieMeasureFlow"; Permissions: users-modify
Name: "{commonappdata}\Tielogic\TieMeasureFlow\uploads"; Permissions: users-modify
Name: "{commonappdata}\Tielogic\TieMeasureFlow\camera"; Permissions: users-modify
Name: "{commonappdata}\Tielogic\TieMeasureFlow\log"; Permissions: users-modify
[Files]
Source: "{#Origine}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
[Icons]
Name: "{group}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"
Name: "{group}\Impronta di questo computer"; Filename: "{app}\TieMeasureFlow.exe"; Parameters: "--impronta"
; l'exe non ha finestra: per fermarlo si chiude il processo
Name: "{group}\Arresta TieMeasureFlow"; Filename: "{sys}\taskkill.exe"; Parameters: "/IM TieMeasureFlow.exe /F"; Flags: runminimized
Name: "{group}\Cartella dati e configurazione"; Filename: "{commonappdata}\Tielogic\TieMeasureFlow"
Name: "{group}\Disinstalla TieMeasureFlow"; Filename: "{uninstallexe}"
Name: "{autodesktop}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"; Tasks: desktopicon
[Run]
Filename: "{app}\TieMeasureFlow.exe"; Description: "Avvia TieMeasureFlow"; Flags: nowait postinstall skipifsilent
+601
View File
@@ -0,0 +1,601 @@
"""Entry point del pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
Cosa fa: controlla la licenza (prodotto "tiemeasureflow"); se non e' valida mostra il
motivo e l'impronta del computer, scrive l'impronta in
%PROGRAMDATA%\\Tielogic\\licenze\\impronta-tiemeasureflow.txt ed esce con 1.
Se e' valida: prepara la cartella dati (%PROGRAMDATA%\\Tielogic\\TieMeasureFlow, con
config.env dei segreti generati al primo avvio), imposta le variabili d'ambiente del
contratto, esegue le migrazioni Alembic (SQLite su file) e avvia in un solo processo
quattro server, ciascuno nel suo thread:
web Flask (src/frontend/flask_app) servito da waitress, --host (default 127.0.0.1) :8780
backend FastAPI src.backend.main:app, 127.0.0.1:8000
vision FastAPI src.vision_worker.main:app, 127.0.0.1:8100
camera scripts/camera_service.py (camere della stazione), 127.0.0.1:8765
Poi apre il browser. Se un'altra copia e' gia' in esecuzione apre solo il browser.
Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radice del repo
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
`--controlla-strumenti` (TieMeasureFlow#82): NON richiede la licenza, non avvia server, migrazioni
ne' database; registra gli strumenti, scrive su stdout e in
%PROGRAMDATA%\\Tielogic\\TieMeasureFlow\\log\\controllo-strumenti.json un JSON
{strumenti, kind, falliti, unavailable} ed esce con 0 (strumenti > 0 e nessun fallito) o 3.
La usa build.ps1 prima di Inno Setup.
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
⚠️ L'exe e' senza console: stdout/stderr possono essere None, quindi il log va su file
(<dati>\\log\\tiemeasureflow.log) e uvicorn gira con log_config=None.
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
⚠️ Nel binario Nuitka mancano i metadati di vs-tools, quindi `load_tools()` (che cerca gli
strumenti come entry point) non ne trova nessuno: `build.ps1` genera `_strumenti.py`
(MODULI, dagli entry point del venv) e `registra_strumenti()` li importa per nome (#82).
"""
from __future__ import annotations
import argparse
import ctypes
import importlib
import json
import logging
import os
import secrets
import socket
import sqlite3
import sys
import threading
import time
import urllib.request
import webbrowser
from pathlib import Path
from visionsuite import license as lic # contratto licenza v1: impronta, cerca_licenza, verifica
PRODOTTO = "tiemeasureflow"
TITOLO = "TieMeasureFlow"
PORTA_WEB = 8780
PORTA_BACKEND = 8000
PORTA_VISION = 8100
PORTA_CAMERA = 8765
log = logging.getLogger("tmf.avvio")
# Nuitka definisce __compiled__ nel modulo compilato; da sorgente non esiste
COMPILATO = "__compiled__" in globals()
# Da sorgente (python packaging/windows/avvio.py) la radice e' due livelli sopra questo file
RADICE_SORGENTI = Path(__file__).resolve().parents[2]
# Segreti generati al primo avvio e riletti dopo (nome -> generatore)
SEGRETI = ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD")
# STATION_CODE: senza una stazione l'interfaccia risponde 503; ST-DEFAULT e' quella creata dal seed della pagina di setup
PREDEFINITI = {"STATION_CODE": "ST-DEFAULT"}
def _stream_sicuri() -> None:
# Exe senza console: stdout/stderr sono None e print/logging/fileConfig di alembic
# andrebbero in errore; li si sostituisce con il device nullo
if sys.stdout is None:
sys.stdout = open(os.devnull, "w", encoding="utf-8")
if sys.stderr is None:
sys.stderr = open(os.devnull, "w", encoding="utf-8")
def cartella_tielogic() -> Path:
# %PROGRAMDATA% e' sempre impostata su Windows; il ripiego serve solo a sviluppo/test su altri sistemi
base = os.environ.get("PROGRAMDATA") or str(Path.home() / ".tielogic-dati")
return Path(base) / "Tielogic"
def cartella_dati() -> Path:
return cartella_tielogic() / "TieMeasureFlow"
def cartella_programma() -> Path:
# Cartella dell'exe (P); da sorgente, la radice del repo
if COMPILATO:
return Path(sys.argv[0]).resolve().parent
return RADICE_SORGENTI
def percorso_impronta() -> Path:
return cartella_tielogic() / "licenze" / f"impronta-{PRODOTTO}.txt"
def messaggio(testo: str, titolo: str = TITOLO, errore: bool = True, senza_finestre: bool = False) -> None:
# Sempre su stderr (se esiste); la MessageBox solo su Windows e se non soppressa
if sys.stderr is not None:
print(testo, file=sys.stderr)
if senza_finestre or sys.platform != "win32":
return
# MB_OK | MB_ICONERROR (0x10) oppure MB_ICONINFORMATION (0x40) | MB_TOPMOST (0x40000)
ctypes.windll.user32.MessageBoxW(None, testo, titolo, 0x40000 | (0x10 if errore else 0x40))
# ---------------------------------------------------------------- licenza
def _scrivi_impronta(impronta: str) -> None:
try:
p = percorso_impronta()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(impronta + "\n", encoding="utf-8")
except OSError as e:
log.warning("impronta non scritta: %s", e)
def controlla_licenza(senza_finestre: bool = False) -> bool:
"""True se la licenza e' valida; altrimenti mostra il motivo e l'impronta e torna False."""
try:
percorso = lic.cerca_licenza(PRODOTTO)
lic.verifica(percorso, PRODOTTO)
return True
except lic.LicenzaNonValida as e:
motivo = str(e)
except Exception as e: # impronta non calcolabile, file illeggibile: mai un'eccezione muta nell'exe
motivo = f"Controllo licenza non riuscito: {e}"
try:
impronta = lic.impronta()
except Exception as e:
impronta = None
motivo += f"\nImpronta macchina non disponibile: {e}"
testo = f"{motivo}\n\n"
if impronta:
_scrivi_impronta(impronta)
testo += (f"Impronta di questo computer:\n{impronta}\n\n"
f"Comunicala a Tielogic per ottenere la licenza (salvata anche in {percorso_impronta()}). "
f"La licenza va copiata in {lic.cerca_licenza(PRODOTTO)}.")
messaggio(testo, f"{TITOLO} - licenza", senza_finestre=senza_finestre)
return False
# ---------------------------------------------------------------- configurazione
def _leggi_env(percorso: Path) -> dict[str, str]:
valori: dict[str, str] = {}
for riga in percorso.read_text(encoding="utf-8").splitlines():
riga = riga.strip()
if riga and not riga.startswith("#") and "=" in riga:
k, v = riga.split("=", 1)
valori[k.strip()] = v.strip()
return valori
def carica_segreti(dati: Path) -> dict[str, str]:
"""Legge <dati>\\config.env; i segreti mancanti si generano e si riscrivono (una volta sola)."""
dati.mkdir(parents=True, exist_ok=True)
percorso = dati / "config.env"
valori = _leggi_env(percorso) if percorso.exists() else {}
nuovi = [k for k in SEGRETI if not valori.get(k)]
# valori di partenza modificabili dall'utente in config.env (scritti una volta sola)
mancanti = [k for k in PREDEFINITI if k not in valori]
if nuovi or mancanti:
for k in nuovi:
valori[k] = secrets.token_urlsafe(32)
for k in mancanti:
valori[k] = PREDEFINITI[k]
righe = ["# Generato da TieMeasureFlow al primo avvio. Non condividere questo file.",
"# SETUP_PASSWORD serve per la pagina http://127.0.0.1:8000/api/setup (primo amministratore)."]
righe += [f"{k}={v}" for k, v in valori.items()]
percorso.write_text("\n".join(righe) + "\n", encoding="utf-8")
log.info("segreti generati in %s", percorso)
return valori
def trova_browser() -> str | None:
"""Chrome o Edge per kaleido (grafici dei report PDF, offline)."""
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
local = os.environ.get("LOCALAPPDATA", "")
candidati = [
Path(pf) / "Google/Chrome/Application/chrome.exe",
Path(pf86) / "Google/Chrome/Application/chrome.exe",
Path(local) / "Google/Chrome/Application/chrome.exe" if local else None,
Path(pf86) / "Microsoft/Edge/Application/msedge.exe",
Path(pf) / "Microsoft/Edge/Application/msedge.exe",
]
for c in candidati:
if c is not None and c.is_file():
return str(c)
return None
def imposta_ambiente(dati: Path, host: str, valori: dict[str, str]) -> None:
"""Variabili d'ambiente del contratto v1; da chiamare PRIMA di importare backend e frontend."""
uploads = dati / "uploads"
env = {
"APP_ENV": "production",
# barre "/" nel percorso: l'URL SQLAlchemy non vuole le barre rovesciate
"DB_URL": f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}",
"UPLOAD_DIR": str(uploads),
"VISION_WORKER_URL": f"http://127.0.0.1:{PORTA_VISION}",
"VISION_RECIPES_ROOT": str(uploads),
"API_SERVER_URL": f"http://127.0.0.1:{PORTA_BACKEND}",
"SESSION_COOKIE_SECURE": "0", # niente HTTPS in locale: un cookie Secure verrebbe scartato
"SERVER_CORS_ORIGINS": f"http://127.0.0.1:{PORTA_WEB},http://localhost:{PORTA_WEB}",
**{k: valori[k] for k in SEGRETI},
}
# le altre chiavi di config.env (STATION_CODE, STATION_SWITCH_ENABLED, ...) passano cosi' come sono,
# ma non sovrascrivono le variabili fisse del contratto
for k, v in valori.items():
env.setdefault(k, v)
programma = cartella_programma()
versione = programma / "vision-engine-version.txt" # scritto da build.ps1
if versione.is_file():
env["VISION_ENGINE_VERSION"] = versione.read_text(encoding="utf-8").strip()
gtk = programma / "gtk" # DLL di WeasyPrint (piano B)
if gtk.is_dir():
env["WEASYPRINT_DLL_DIRECTORIES"] = str(gtk)
if hasattr(os, "add_dll_directory"):
# cintura e bretelle: WeasyPrint salta add_dll_directory se trova sys.frozen
try:
os.add_dll_directory(str(gtk))
except OSError:
pass
browser = trova_browser()
if browser:
env["BROWSER_PATH"] = browser
os.environ.update(env)
for cartella in (uploads, dati / "camera", dati / "log"):
cartella.mkdir(parents=True, exist_ok=True)
def _prepara_percorsi_sorgenti() -> None:
# Da sorgente i moduli del frontend (config, services, blueprints, app) e lo script camera
# non sono pacchetti: si aggiungono al path. Compilati, Nuitka li ha gia' incorporati.
if COMPILATO:
return
for p in (RADICE_SORGENTI / "scripts", RADICE_SORGENTI / "src" / "frontend" / "flask_app", RADICE_SORGENTI):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
# ---------------------------------------------------------------- log
def _prepara_log(dati: Path | None = None) -> None:
# Exe senza console: il log va su file; se la cartella non e' scrivibile si prosegue senza.
# Idempotente: alembic (fileConfig in env.py) azzera i gestori del root, quindi si richiama dopo.
try:
d = (dati or cartella_dati()) / "log"
d.mkdir(parents=True, exist_ok=True)
percorso = str(d / "tiemeasureflow.log")
root = logging.getLogger()
for h in root.handlers:
if isinstance(h, logging.FileHandler) and h.baseFilename.lower() == os.path.abspath(percorso).lower():
break
else:
h = logging.FileHandler(percorso, encoding="utf-8")
h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
root.addHandler(h)
root.setLevel(logging.INFO)
for nome in ("tmf.avvio",):
logging.getLogger(nome).disabled = False # fileConfig disattiva i logger esistenti
except OSError:
pass
# ---------------------------------------------------------------- strumenti di visione
def registra_strumenti() -> list[str]:
"""Registra gli strumenti di Core; torna i moduli che non si sono importati (#82).
Nel pacchetto Nuitka gli entry point non esistono: `load_tools()` non trova nulla e
l'elenco dei moduli arriva da `_strumenti.py`, generato da build.ps1. Da sorgente
`_strumenti` non c'e' e bastano gli entry point. Il worker di visione gira in un thread
di questo processo: registrare una volta sola qui vale per tutti."""
from visionsuite.task.registry import load_tools
load_tools()
try:
from _strumenti import MODULI # generato da build.ps1, accanto ad avvio.py
except ImportError:
MODULI = () # da sorgente: gli entry point di load_tools() bastano
falliti: list[str] = []
for modulo in MODULI:
try:
importlib.import_module(modulo) # eseguire il modulo registra i suoi @tool
except Exception: # un modulo rotto non ferma l'app: si logga e si raccoglie
log.error("strumenti %s non importati", modulo, exc_info=True)
falliti.append(modulo)
return falliti
def controlla_strumenti() -> int:
"""Senza licenza, server ne' DB: scrive il JSON del catalogo strumenti. Exit 0 se completo, 3 altrimenti."""
from visionsuite.task.graph import registry
from visionsuite.task.registry import unavailable
falliti = registra_strumenti()
kind = sorted(registry())
esito = {"strumenti": len(kind), "kind": kind, "falliti": falliti, "unavailable": unavailable()}
riga = json.dumps(esito, ensure_ascii=False)
if sys.stdout is not None:
print(riga)
try:
f = cartella_dati() / "log" / "controllo-strumenti.json"
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(riga + "\n", encoding="utf-8") # l'exe e' senza console: il file e' l'unica uscita sicura
except OSError as e:
log.warning("controllo-strumenti.json non scritto: %s", e)
return 0 if kind and not falliti else 3
# ---------------------------------------------------------------- rete
def _porta_aperta(porta: int) -> bool:
try:
with socket.create_connection(("127.0.0.1", porta), timeout=0.5):
return True
except OSError:
return False
def _e_tmf() -> bool:
# Una copia gia' in esecuzione: il backend risponde su /api/health con il nome del servizio
try:
with urllib.request.urlopen(f"http://127.0.0.1:{PORTA_BACKEND}/api/health", timeout=2) as r:
return "TieMeasureFlow" in str(json.load(r).get("service", ""))
except Exception:
return False
def _nessun_utente(db: Path) -> bool:
"""True se il database esiste ed e' senza utenti (primo avvio: serve la pagina di setup)."""
try:
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
try:
return con.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 0
finally:
con.close()
except Exception:
return False
def camere_da_scaldare(db: Path) -> list[tuple[str, str]]:
"""Coppie (camera_url, camera_source) configurate nel db (stazioni e task di ricetta), senza doppioni.
Solo sorgenti "camera:" che aprono hardware (non "camera:folder"). Errore sqlite -> lista vuota (#108)."""
coppie: list[tuple[str, str]] = []
def aggiungi(url, src) -> None:
# #108: valida la coppia e scarta duplicati e sorgenti che non aprono hardware
if not (isinstance(url, str) and isinstance(src, str) and url.strip() and src.strip()):
return
if not src.startswith("camera:") or src == "camera:folder":
return
if (url, src) not in coppie:
coppie.append((url, src))
try:
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
try:
for url, src in con.execute("SELECT camera_url, camera_source FROM stations"):
aggiungi(url, src)
for (blob,) in con.execute("SELECT acquisition_json FROM recipe_tasks WHERE acquisition_json IS NOT NULL"):
try:
cfg = json.loads(blob)
except (TypeError, ValueError):
continue # #108: JSON non valido, si salta
if isinstance(cfg, dict):
aggiungi(cfg.get("camera_url"), cfg.get("camera_source"))
finally:
con.close()
except Exception:
return []
return coppie
def scalda_camere(db: Path) -> None:
"""Apre ogni camera configurata con un grab a vuoto, cosi' il primo "Acquisisci" non aspetta ~25 s (#108).
Gira in un thread daemon: nessuna eccezione esce."""
try:
camere = camere_da_scaldare(db)
if not camere:
return
for _ in range(300): # aspetta il servizio camera (max ~60 s), come _apri_browser_quando_pronto
if _porta_aperta(PORTA_CAMERA):
break
time.sleep(0.2)
else:
log.warning("scalda camere: servizio camera non raggiungibile, salto")
return
for url, src in camere:
t0 = time.monotonic()
try:
richiesta = urllib.request.Request(
f"{url.rstrip('/')}/camera/grab",
data=json.dumps({"source": src, "trigger": "continuo", "timeout_ms": 5000}).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(richiesta, timeout=90) as r:
esito = f"HTTP {r.status}"
log.info("scalda camera %s: %s in %.1f s", src, esito, time.monotonic() - t0)
except Exception as e: # #108: una camera che non risponde non ferma le altre
log.warning("scalda camera %s: %s dopo %.1f s", src, e, time.monotonic() - t0)
except Exception:
log.exception("scalda camere non riuscito")
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
for _ in range(300):
if all(_porta_aperta(p) for p in porte):
break
time.sleep(0.2)
webbrowser.open(url)
# ---------------------------------------------------------------- migrazioni
def _neutralizza_fileconfig() -> None:
# env.py di Alembic chiama logging.config.fileConfig (anche la pagina di setup, con command.stamp):
# azzererebbe i gestori del log su file. Nel pacchetto il log lo decide avvio.py.
import logging.config
logging.config.fileConfig = lambda *a, **k: None
def esegui_migrazioni() -> None:
"""alembic upgrade head. Compilato: env.py e versions\\ sono soli .pyc, quindi sourceless."""
_neutralizza_fileconfig()
from alembic import command
from alembic.config import Config
import src.backend as pacchetto
migrazioni = Path(pacchetto.__file__).resolve().parent / "migrations"
cfg = Config(str(migrazioni / "alembic.ini"))
if COMPILATO:
cfg.set_main_option("sourceless", "true")
command.upgrade(cfg, "head")
# ---------------------------------------------------------------- server
class ServerThread(threading.Thread):
"""Un server (uvicorn o waitress) in un thread; `errore` resta valorizzato se muore."""
def __init__(self, nome: str, costruisci):
super().__init__(name=nome, daemon=True)
self._costruisci = costruisci
self.server = None
self.errore: BaseException | None = None
def run(self) -> None:
try:
self.server = self._costruisci()
self.server.run()
except BaseException as e: # un server morto deve comparire nel log e fermare l'insieme
self.errore = e
log.exception("server %s terminato con errore", self.name)
def ferma(self) -> None:
s = self.server
try:
if hasattr(s, "should_exit"):
s.should_exit = True # uvicorn
elif s is not None:
s.close() # waitress
except Exception:
pass
def _uvicorn(app, porta: int):
import uvicorn
# log_config=None: niente console; i thread non toccano i signal handler (uvicorn lo gestisce)
return uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=porta, log_config=None, workers=1))
def costruisci_web(host: str):
import waitress
from app import create_app # src/frontend/flask_app/app.py
return waitress.create_server(create_app(), host=host, port=PORTA_WEB, threads=8)
def costruisci_backend():
from src.backend.main import app
return _uvicorn(app, PORTA_BACKEND)
def costruisci_vision():
from src.vision_worker.main import app
return _uvicorn(app, PORTA_VISION)
def costruisci_camera(cartella: Path):
from camera_service import create_app # scripts/camera_service.py (non e' un pacchetto)
return _uvicorn(create_app(cartella), PORTA_CAMERA)
def avvia_server(host: str, dati: Path) -> list[ServerThread]:
thread = [
ServerThread("backend", costruisci_backend),
ServerThread("vision", costruisci_vision),
ServerThread("camera", lambda: costruisci_camera(dati / "camera")),
ServerThread("web", lambda: costruisci_web(host)),
]
for t in thread:
t.start()
return thread
def main(argv=None) -> int:
_stream_sicuri()
p = argparse.ArgumentParser(prog="TieMeasureFlow", description=TITOLO)
p.add_argument("--impronta", action="store_true", help="mostra l'impronta di questo computer")
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
p.add_argument("--controlla-strumenti", action="store_true",
help="registra gli strumenti, scrive il JSON ed esce (senza licenza ne' server; usata da build.ps1)")
args = p.parse_args(argv)
_prepara_log()
if args.impronta:
try:
impronta = lic.impronta()
except Exception as e:
messaggio(f"Impronta non disponibile: {e}", senza_finestre=args.senza_finestre)
return 1
print(impronta) if sys.stdout is not None else None
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
return 0
if args.controlla_strumenti:
return controlla_strumenti() # prima della licenza: la build non ha una licenza
if not controlla_licenza(args.senza_finestre):
return 1
if _porta_aperta(PORTA_WEB):
if _e_tmf():
# TieMeasureFlow e' gia' in esecuzione: basta riaprire la pagina
if not args.senza_browser:
webbrowser.open(f"http://127.0.0.1:{PORTA_WEB}/")
return 0
messaggio(f"La porta {PORTA_WEB} e' occupata da un altro programma: chiuderlo e riprovare.",
senza_finestre=args.senza_finestre)
return 2
occupate = [str(x) for x in (PORTA_BACKEND, PORTA_VISION, PORTA_CAMERA) if _porta_aperta(x)]
if occupate:
messaggio(f"Porte occupate da altri programmi: {', '.join(occupate)}. Chiuderli e riprovare.",
senza_finestre=args.senza_finestre)
return 2
dati = cartella_dati()
try:
valori = carica_segreti(dati)
imposta_ambiente(dati, args.host, valori)
_prepara_percorsi_sorgenti()
primo_avvio = not (dati / "tmf.db").exists()
esegui_migrazioni()
except Exception as e:
_prepara_log(dati)
log.exception("avvio non riuscito")
messaggio(f"Avvio non riuscito: {e}\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}",
senza_finestre=args.senza_finestre)
return 3
_prepara_log(dati) # alembic ha riconfigurato il logging
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
thread = avvia_server(args.host, dati)
# #108: apre le camere configurate con un grab a vuoto, con o senza browser
threading.Thread(target=scalda_camere, args=(dati / "tmf.db",), daemon=True).start()
if not args.senza_browser:
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
else f"http://127.0.0.1:{PORTA_WEB}/")
threading.Thread(target=_apri_browser_quando_pronto,
args=(url, (PORTA_WEB, PORTA_BACKEND)), daemon=True).start()
# Si resta vivi finche' tutti i server girano; se uno muore si ferma tutto
try:
while thread and all(t.is_alive() for t in thread):
time.sleep(1)
except KeyboardInterrupt:
pass
morti = [t for t in thread if not t.is_alive()]
for t in thread:
t.ferma()
if morti:
messaggio("Un componente si e' fermato: " + ", ".join(f"{t.name} ({t.errore})" for t in morti)
+ f"\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}", senza_finestre=args.senza_finestre)
return 4
return 0
if __name__ == "__main__":
sys.exit(main())
+247
View File
@@ -0,0 +1,247 @@
<#
Pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
Cosa fa: uv sync in un ambiente Python 3.13 separato (build\venv313), frontend (Tailwind +
traduzioni), compila avvio.py con Nuitka in modalita' --standalone (cartella, NON onefile:
l'onefile si decomprime in %TEMP% ad ogni avvio), aggiunge le migrazioni Alembic come soli
.pyc, le DLL di WeasyPrint (piano B) e il commit del motore di visione, poi Inno Setup
produce dist\TieMeasureFlow-setup-<versione>.exe. Sul PC del cliente non finisce nessun
.py; i .pyc ci sono solo in src\backend\migrations (Alembic le carica dal disco).
Come si usa (dalla radice del repo, con il submodule vendor/visionsuite inizializzato):
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 [-Versione 3.0.0]
-Versione accetta anche un tag (v3.0.0, v3.0.0-rc1): si usano i numeri iniziali.
Senza -Versione si usa la variabile GITHUB_REF_NAME (tag nella CI) o 0.0.0.
-SenzaSetup si ferma dopo Nuitka (cartella build\nuitka\avvio.dist).
Dipende da: uv, node/npx (Tailwind 3.4.19 da npm: serve Internet SOLO a chi costruisce),
Visual Studio con carico C++ (MSVC; Nuitka lo trova da solo), Inno Setup 6
(`winget install --id JRSoftware.InnoSetup -e`; ISCC.exe nel PATH, in Program Files o in
%LOCALAPPDATA%\Programs). Nuitka e' nel gruppo uv "windows" (pyproject.toml).
⚠️ La prima compilazione e' lunga (decine di minuti: numpy/opencv/scipy/plotly); le successive
riusano la cache di Nuitka (%LOCALAPPDATA%\Nuitka) e la cartella build\nuitka.
⚠️ I pacchetti di Core condividono il namespace visionsuite (extend_path): Nuitka non lo
segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ installati.
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
funziona ma e' piu' lento nel matching.
⚠️ Gli strumenti di vs-tools si registrano solo come entry point (visionsuite.task.tools) e i
metadati di vs-tools non stanno nel binario (TieMeasureFlow#82): il build genera
packaging\windows\_strumenti.py (MODULI, gitignorato) che avvio.py importa, e prima di
Inno Setup esegue l'exe con --controlla-strumenti: se il catalogo e' vuoto il build fallisce.
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
#>
param(
[string]$Versione = $env:GITHUB_REF_NAME,
[switch]$SenzaSetup
)
$ErrorActionPreference = 'Stop'
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
Set-Location $radice
# Versione numerica a 4 parti per i metadati dell'exe (Nuitka la vuole cosi')
if (-not $Versione) { $Versione = '0.0.0' }
if ($Versione -notmatch '^v?(\d+)\.(\d+)\.(\d+)') { throw "Versione non valida: '$Versione' (atteso es. v3.0.0)" }
$ver3 = "$($Matches[1]).$($Matches[2]).$($Matches[3])"
$ver4 = "$ver3.0"
Write-Host "== TieMeasureFlow $ver3 =="
# Controlli: se manca qualcosa il messaggio dice come installarlo
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
throw "uv non trovato. Installarlo: https://docs.astral.sh/uv/getting-started/installation/"
}
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
throw "node/npx non trovato (serve a compilare Tailwind). Installarlo da https://nodejs.org"
}
if (-not (Test-Path (Join-Path $radice 'vendor\visionsuite\packages\vs-license'))) {
throw "Submodule vendor/visionsuite mancante o vecchio: git submodule update --init --recursive"
}
$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source
if (-not $iscc) {
foreach ($c in @("${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")) {
if (Test-Path $c) { $iscc = $c; break }
}
}
if (-not $iscc -and -not $SenzaSetup) { throw "Inno Setup 6 non trovato. Installarlo: winget install --id JRSoftware.InnoSetup -e" }
# Ambiente separato da quello di sviluppo (3.11): extra server + client + vision-worker + gruppo windows
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313'
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
$vn = & $py -m nuitka --version # niente Select-Object -First: chiude la pipe e falsa l'exit code
if ($LASTEXITCODE -ne 0) { throw "Nuitka non disponibile (serve anche un compilatore C: Visual Studio con 'Sviluppo di applicazioni desktop con C++')" }
# Frontend: tailwind.css non e' nel repo (stessa versione esatta di Dockerfile.frontend) e traduzioni .mo
$flask = Join-Path $radice 'src\frontend\flask_app'
Push-Location $flask
try {
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') compile -d translations
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
} finally { Pop-Location }
# Sottopacchetti del namespace visionsuite (core, camera, task, tools, license, ...)
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
# Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui
# pacchetto (visionsuite.camera, namespace) non e' di primo livello. Gli entry point di vs-tools, che
# load_tools() cercherebbe nei metadati, si rimpiazzano con _strumenti.py (sotto, TieMeasureFlow#82).
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
# del LEGGIMI.md, con l'eventuale commento "# ..." in coda. I dati di src/backend/templates sono gia' sopra.
$flagPdf = @()
$leggimi = Join-Path $PSScriptRoot 'pdf\LEGGIMI.md'
if (Test-Path $leggimi) {
$flagPdf = @(Get-Content $leggimi | ForEach-Object {
if ($_ -match '^\s*(--(?:include|nofollow|noinclude|enable)[^\s`#]*)\s*(?:#.*)?$' -and $Matches[1] -notlike '--include-data-dir=src/backend/templates*') { $Matches[1] }
})
Write-Host "Flag Nuitka da pdf\LEGGIMI.md: $($flagPdf -join ' ')"
}
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
# TieMeasureFlow#82: i moduli-strumento dagli entry point del venv del build (nel binario non ci sono i
# metadati di vs-tools, quindi load_tools() non li troverebbe). Si usa $py, non `uv run`.
$strumenti = Join-Path $radice 'packaging\windows\_strumenti.py'
$genera = @'
import sys
from importlib.metadata import entry_points
moduli = sorted({ep.value for ep in entry_points(group="visionsuite.task.tools")})
if not moduli:
sys.exit(1)
righe = ["# Generato da build.ps1 (TieMeasureFlow#82): non modificare, non committare.", "MODULI = ("]
righe += [f" {m!r}," for m in moduli] + [")"]
with open(sys.argv[1], "w", encoding="utf-8", newline="") as f:
print(*righe, sep=chr(10), file=f)
print(len(moduli))
'@
$nModuli = $genera | & $py - $strumenti
if ($LASTEXITCODE -ne 0) { throw "Entry point visionsuite.task.tools non trovati nel venv: nessuno strumento da includere" }
Write-Host "Moduli-strumento ($nModuli) in $strumenti"
$out = Join-Path $radice 'build\nuitka'
New-Item -ItemType Directory -Force $out | Out-Null
$nuitka = @(
'--standalone',
'--assume-yes-for-downloads',
'--msvc=latest',
'--windows-console-mode=disable', # nessuna finestra nera: gli errori escono in MessageBox e nel log
"--output-dir=$out",
'--output-filename=TieMeasureFlow.exe',
'--windows-icon-from-ico=packaging\windows\TieMeasureFlow.ico',
'--company-name=Tielogic',
'--product-name=TieMeasureFlow',
"--file-version=$ver4",
"--product-version=$ver4",
'--file-description=TieMeasureFlow',
'--copyright=Tielogic',
# codice del prodotto: backend, worker di visione, runner (src e' un pacchetto, flask_app no)
'--include-package=src.backend',
'--include-package=src.vision',
'--include-package=src.vision_worker',
# le migrazioni NON si compilano: Alembic le carica dal disco, vanno come .pyc (sotto)
'--nofollow-import-to=src.backend.migrations',
'--nofollow-import-to=src.backend.tests',
'--nofollow-import-to=src.vision.tests',
'--nofollow-import-to=src.vision_worker.tests',
# frontend Flask e servizio camera: moduli di primo livello trovati via PYTHONPATH
'--include-module=app',
'--include-module=config',
'--include-package=blueprints',
'--include-package=services',
'--include-module=camera_service',
'--nofollow-import-to=blueprints.tests',
'--nofollow-import-to=tests',
'--include-package=uvicorn', # loop e protocolli di uvicorn si caricano per nome
'--include-package=waitress',
'--include-package=alembic',
'--include-module=aiosqlite',
'--include-module=sqlalchemy.dialects.sqlite.aiosqlite',
'--include-package=harvesters', # camere GenTL (GigE): il producer .cti e' del vendor
'--include-package=genicam',
'--include-package-data=genicam',
'--include-package-data=babel', # locale-data di Babel (flask-babel)
'--nofollow-import-to=numba', # vedi nota in testa: il ripiego in Python puro
'--nofollow-import-to=llvmlite',
# dati letti dal disco con __file__: setup page, report, Flask (templates/static/translations alla radice)
'--include-data-dir=src\backend\templates=src\backend\templates',
'--include-data-files=src\backend\migrations\alembic.ini=src\backend\migrations\alembic.ini',
'--include-data-dir=src\frontend\flask_app\templates=templates',
'--include-data-dir=src\frontend\flask_app\static=static',
'--include-data-dir=src\frontend\flask_app\translations=translations',
'--include-data-files=pyproject.toml=pyproject.toml' # config.py ne legge la versione per il piede di pagina
) + $includi + $flagPdf + @('packaging\windows\avvio.py')
$t0 = Get-Date
& $py -m nuitka @nuitka
if ($LASTEXITCODE -ne 0) { throw "Nuitka fallito (codice $LASTEXITCODE)" }
Write-Host ("Nuitka: {0:N0} minuti" -f ((Get-Date) - $t0).TotalMinutes)
$sorgente = Join-Path $out 'avvio.dist'
if (-not (Test-Path (Join-Path $sorgente 'TieMeasureFlow.exe'))) { throw "exe non trovato in $sorgente" }
# Migrazioni Alembic: env.py e versions\ come soli .pyc (compileall -b: .pyc accanto al sorgente, che si cancella)
$mig = Join-Path $sorgente 'src\backend\migrations'
New-Item -ItemType Directory -Force (Join-Path $mig 'versions') | Out-Null
Copy-Item 'src\backend\migrations\env.py' $mig -Force
Copy-Item 'src\backend\migrations\versions\*.py' (Join-Path $mig 'versions') -Force
& $py -m compileall -b -q $mig
if ($LASTEXITCODE -ne 0) { throw "compileall delle migrazioni fallito" }
Get-ChildItem $mig -Recurse -Filter *.py -File | Remove-Item -Force
Get-ChildItem $mig -Recurse -Directory -Filter __pycache__ | Remove-Item -Recurse -Force
# alembic.ini del pacchetto: sourceless, perche' anche la pagina di setup (command.stamp) apre
# alembic.ini con un Config suo e non troverebbe env.py/versions (solo .pyc)
$ini = Join-Path $mig 'alembic.ini'
$testoIni = [System.IO.File]::ReadAllText($ini)
if ($testoIni -notmatch '(?m)^script_location') { throw "alembic.ini senza script_location" }
$testoIni = $testoIni -replace '(?m)^(script_location[^\r\n]*)', "`$1`nsourceless = true"
[System.IO.File]::WriteAllText($ini, $testoIni, (New-Object System.Text.UTF8Encoding $false))
# Commit del motore di visione: avvio.py lo legge in VISION_ENGINE_VERSION
$motore = (git -C vendor/visionsuite rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or -not $motore) { throw "Commit di vendor/visionsuite non leggibile" }
[System.IO.File]::WriteAllText((Join-Path $sorgente 'vision-engine-version.txt'), $motore, (New-Object System.Text.UTF8Encoding $false))
# DLL di WeasyPrint (piano PDF): lo script le scarica con sha256 pinnato
$gtk = Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1'
if (Test-Path $gtk) {
powershell -NoProfile -ExecutionPolicy Bypass -File $gtk -Destinazione (Join-Path $sorgente 'gtk')
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
} else {
Write-Warning "packaging\windows\pdf\prendi-gtk.ps1 assente: il pacchetto non avra' le DLL di WeasyPrint (niente report PDF)"
}
# Il pacchetto non deve contenere codice sorgente o bytecode, tranne i .pyc delle migrazioni
$sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
# TieMeasureFlow#82: l'exe deve vedere gli strumenti (--controlla-strumenti non vuole licenza ne' apre server/DB)
$exe = Join-Path $sorgente 'TieMeasureFlow.exe'
$jsonControllo = Join-Path $env:PROGRAMDATA 'Tielogic\TieMeasureFlow\log\controllo-strumenti.json'
Remove-Item $jsonControllo -ErrorAction SilentlyContinue # un JSON vecchio non deve far passare il controllo
# l'exe e' GUI-subsystem: con & non si aspetta, serve Start-Process -Wait
$proc = Start-Process -FilePath $exe -ArgumentList '--controlla-strumenti' -Wait -PassThru
$esito = if (Test-Path $jsonControllo) { Get-Content $jsonControllo -Raw } else { '(controllo-strumenti.json non scritto)' }
if ($proc.ExitCode -ne 0) { throw "Controllo strumenti fallito (codice $($proc.ExitCode)): $esito" }
$nStrumenti = ($esito | ConvertFrom-Json).strumenti
Write-Host "Controllo strumenti: $nStrumenti tipi registrati nell'exe"
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
$dist = Join-Path $radice 'dist'
New-Item -ItemType Directory -Force $dist | Out-Null
& $iscc "/DVersione=$ver3" "/DVersione4=$ver4" "/DOrigine=$sorgente" "/DUscita=$dist" (Join-Path $radice 'packaging\windows\TieMeasureFlow.iss')
if ($LASTEXITCODE -ne 0) { throw "Inno Setup fallito" }
$setup = Join-Path $dist "TieMeasureFlow-setup-$ver3.exe"
Write-Host ("Pronto: {0} ({1:N1} MB)" -f $setup, ((Get-Item $setup).Length / 1MB))
+3
View File
@@ -0,0 +1,3 @@
# Le DLL scaricate da prendi-gtk.ps1 non si committano: solo lo script (TieMeasureFlow#78).
gtk/
cache/
+149
View File
@@ -0,0 +1,149 @@
# Report PDF nel pacchetto Windows
Questa cartella contiene quello che serve perché i report PDF di TieMeasureFlow
(`src/backend/services/report_service.py`: Jinja2, Plotly con Kaleido 1.2.0, WeasyPrint 68.1)
funzionino su Windows, dentro il pacchetto, **senza Internet**. Riferimento: TieMeasureFlow#78
(parte del pacchetto Windows, madre #76).
## Come funziona
Un report SPC passa da due componenti nativi che su Windows non arrivano con `pip`:
1. **WeasyPrint** disegna il PDF e carica a runtime le DLL di Pango/GTK
(`libgobject-2.0-0`, `libpango-1.0-0`, `libharfbuzz-0`, `libharfbuzz-subset-0`,
`libfontconfig-1`, `libpangoft2-1.0-0` e le loro dipendenze).
2. **Kaleido 1.x** trasforma i grafici Plotly in SVG pilotando un browser Chromium già installato
(non porta più Chromium con sé). Su ogni Windows 10/11 c'è Edge.
### Le DLL: `prendi-gtk.ps1`
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\windows\pdf\prendi-gtk.ps1 -Destinazione <cartella>
```
- Scarica da `repo.msys2.org` (ucrt64) 21 pacchetti **pinnati con sha256** (nome file e hash stanno nello
script), verifica l'hash, estrae solo le 27 DLL necessarie, `etc\fonts` di fontconfig e le licenze.
- Idempotente: se la cartella è già pronta (file `.gtk-pin` uguale ai pin dello script) esce subito.
- Se un hash non coincide fallisce e non lascia mezza cartella.
- I pacchetti scaricati stanno in `%LOCALAPPDATA%\Tielogic\tmf-gtk-cache` (`-Cache` per cambiarla).
- Serve Internet **solo alla build**. Estrae `.zst` con `tar.exe` di Windows (verificato: bsdtar 3.8.9 con
zstd); se `tar.exe` non sa di zstd ripiega su `uv run --with zstandard`.
- Gira da utente non admin con PowerShell 5.1.
- ⚠️ `repo.msys2.org` tiene solo le versioni recenti: se un file pinnato sparisce (404) vanno aggiornati nome
e sha256 dal db `https://repo.msys2.org/mingw/ucrt64/ucrt64.db` e rifatto il test. Conservare la cache
(Wasabi o NAS) evita la corsa contro il repository.
Il piano C copia `<cartella>` in `P\gtk\` (P = cartella programma).
### Variabili d'ambiente (le imposta `avvio.py`, piano C)
| Variabile | Valore | Perché |
|---|---|---|
| `WEASYPRINT_DLL_DIRECTORIES` | `<P>\gtk` | WeasyPrint chiama `os.add_dll_directory` su ogni cartella (separatore `;`) **prima** di caricare le DLL. Va impostata prima di importare `weasyprint` (lo fa `report_service` all'import). |
| `BROWSER_PATH` | `msedge.exe` o `chrome.exe` | Choreographer (sotto Kaleido) la legge da sé e ha la precedenza su ogni ricerca (verificato: un percorso inesistente dà `FileNotFoundError`). Senza la variabile Kaleido cerca Edge/Chrome da solo (registro e percorsi tipici). Nessun codice in `report_service.py` per questo. |
Fontconfig non richiede variabili: trova `etc\fonts` accanto alle DLL (verificato). Se manca `etc\fonts`
WeasyPrint stampa "Fontconfig error: Cannot load default config file". I font dei report vengono da
`C:\Windows\Fonts`.
### MathJax: la modifica a `report_service.py`
Kaleido 1.x inserisce nella pagina di render uno `<script>` MathJax da `cdnjs.cloudflare.com` anche quando i
grafici non hanno formule. Misurato su daitarn con Edge: con rete che scarta i pacchetti ogni grafico aspetta
~19 s (due grafici per report SPC); con rete che rifiuta subito ~2 s. `report_service.py` imposta
`pio.defaults.mathjax` su un file `.js` vuoto in `%TEMP%` (`tmf-mathjax-vuoto.js`): nessuna richiesta di rete,
report SPC in ~7 s anche a rete bloccata. Se un giorno i grafici useranno LaTeX, qui serve un MathJax locale.
`plotly.min.js` invece è già locale (dentro il pacchetto `plotly`).
## Flag Nuitka e dati da includere (per `build.ps1`, piano C)
⚠️ Stato: ricavati leggendo i sorgenti installati (versioni del `uv.lock`); **non provati con una build
Nuitka**: la verifica è la prima build di C. I dati e i metadati sono quelli che il codice legge davvero da disco.
Dati dei pacchetti (file non `.py` letti a runtime):
```text
--include-package-data=weasyprint # css/html5_ua.css, html5_ph.css, html5_ua_form.css, pdf/sRGB2014.icc
--include-package-data=pyphen # dictionaries/*.dic (84 file, sillabazione)
--include-package-data=kaleido # vendor/kaleido_scopes.js (pagina di render)
--include-package-data=plotly # package_data/plotly.min.js (Kaleido lo cerca come file accanto a plotly/__init__), templates/*.json, validators/_validators.json
--include-package-data=tzdata # zoneinfo: Windows non ha il database IANA (vedi sotto)
--include-data-dir=src/backend/templates=src/backend/templates # Jinja2: report_service legge i .html dal disco
```
`choreographer/resources/last_known_good_chrome.json` serve solo al comando che scarica Chrome: non serve.
Metadati delle distribuzioni (`importlib.metadata.version` al momento dell'import): senza, `import plotly` e
Kaleido falliscono con `PackageNotFoundError`:
```text
--include-distribution-metadata=plotly
--include-distribution-metadata=kaleido
```
Import dinamici che l'analisi statica di Nuitka può perdere:
```text
--include-package=fontTools # ttLib/ttFont.py importa le tabelle con __import__("fontTools.ttLib.tables." + tag)
--include-package=plotly # _plotly_utils/importers.py: importlib.import_module lazy dei moduli graph_objs
--include-package=_plotly_utils
--include-module=_cffi_backend # WeasyPrint usa cffi in modalita' ABI (dlopen), serve il modulo C di cffi
--include-package=weasyprint
--include-package=kaleido
--include-package=choreographer
```
Altre note per C:
- WeasyPrint salta `os.add_dll_directory` se `sys.frozen` esiste (`weasyprint/text/ffi.py`). Nuitka di norma
non lo imposta (PyInstaller sì): se in `TieMeasureFlow.exe` le DLL non si trovassero, chiamare
`os.add_dll_directory(<P>\gtk)` da `avvio.py` prima dell'import.
- Le DLL **non** vanno compilate né incluse da Nuitka: restano file in `P\gtk\`.
- Il controllo "nessun `.py`" di `build.ps1` non deve scandire `gtk\` (non ci sono `.py`, ma è meglio
saperlo) né `licenze\`.
- **`tzdata` manca da `pyproject.toml`**: su Windows `ZoneInfo("Europe/Rome")` (usato da
`src/backend/utils/tempo.py`, quindi da ogni report) dà `ZoneInfoNotFoundError`. Va aggiunto
`tzdata; sys_platform == 'win32'` agli extra server e client (o al gruppo `windows`) e il frontend
(`src/frontend/flask_app/app.py` usa `ZoneInfo`) ne ha bisogno allo stesso modo.
- `pdfplumber` è già nell'extra `server` (il test lo usa per leggere i PDF).
## Licenze delle DLL
Le DLL sono binari MSYS2/MinGW (ucrt64) non modificati, collegati dinamicamente. `prendi-gtk.ps1` mette in
`<cartella>\licenze\` i testi di licenza di ogni pacchetto più un `<pacchetto>.txt` con nome del file e licenza.
**L'installer deve includere `gtk\licenze\`.**
| Libreria | Licenza |
|---|---|
| Pango, GLib/GObject/GIO, fribidi, libthai, libdatrie, graphite2, libintl (gettext-runtime), libiconv | LGPL 2.1 o successive |
| FreeType | FTL oppure GPL 2+ (si sceglie FTL) |
| HarfBuzz, expat, libffi, brotli, fontconfig | MIT / permissiva |
| libpng, zlib-ng, bzip2, pcre2 | permissive (libpng, Zlib, bzip2, BSD-3) |
| libgcc, libstdc++ | GPL 3 con GCC Runtime Library Exception (ridistribuzione libera) |
| libwinpthread | MIT e BSD-3 |
Per le librerie LGPL, chi riceve il pacchetto può sostituire le DLL (sono file separati in `gtk\`); i sorgenti
sono pubblici su MSYS2 (`https://packages.msys2.org/`), e l'elenco esatto delle versioni è nello script.
## Test
`src/backend/tests/test_report_windows.py` (saltato fuori da Windows, senza DLL, senza Edge/Chrome o senza
`tzdata`): in un sottoprocesso con WeasyPrint **vero** (il `conftest.py` altrimenti lo sostituisce con un
mock) genera su SQLite in memoria il report SPC (carta di controllo e istogramma) e quello a tabella, e
controlla con `pdfplumber` byte `%PDF-`, almeno una pagina, titoli dei grafici e segni vettoriali.
```powershell
# DLL (una volta) e test
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\windows\pdf\prendi-gtk.ps1 -Destinazione packaging\windows\pdf\gtk
uv run --python 3.13 --extra server --extra dev pytest src/backend/tests/test_report_windows.py -q
```
`packaging/windows/pdf/gtk/` e `cache/` sono in `.gitignore`: le DLL non si committano.
### Prova a rete bloccata
`HTTP_PROXY`/`HTTPS_PROXY` non bastano: Edge e Chrome usano il **proxy di sistema** di Windows. Per provare davvero
il PC senza Internet: impostare temporaneamente il proxy di sistema (Impostazioni, Rete, Proxy) su un indirizzo
morto (`127.0.0.1:9` rifiuta subito; `10.255.255.1:8080` scarta i pacchetti, il caso peggiore), generare i
report e **ripristinare** il proxy. Esito del 2026-10-17 con proxy che scarta i pacchetti: report SPC e report a
tabella completi in ~7 s.
+143
View File
@@ -0,0 +1,143 @@
<#
prendi-gtk.ps1 - prepara le DLL di Pango/GTK che WeasyPrint 68.1 vuole su Windows.
COSA FA: scarica da repo.msys2.org (ucrt64) un elenco FISSO di pacchetti, ne verifica lo sha256,
estrae solo le DLL che servono a WeasyPrint (+ etc\fonts di fontconfig e le licenze) in -Destinazione.
COME SI USA: powershell -NoProfile -ExecutionPolicy Bypass -File prendi-gtk.ps1 -Destinazione <cartella>
Opzionali: -Cache <cartella> (pacchetti scaricati, default %LOCALAPPDATA%\Tielogic\tmf-gtk-cache),
-Forza (rifa' anche se la cartella e' gia' pronta).
DA COSA DIPENDE: Windows 10/11 con tar.exe (bsdtar con zstd) oppure uv + Python (modulo zstandard);
Internet solo al momento della build, mai a runtime. PowerShell 5.1 va bene, non serve admin.
IDEMPOTENTE: se la cartella ha gia' le DLL e il file .gtk-pin coincide con questi pin, esce senza scaricare.
FALLISCE se lo sha256 di un pacchetto non coincide (nessun file parziale resta in -Destinazione).
TRAPPOLA: repo.msys2.org tiene solo le versioni recenti. Se un file pinnato sparisce (404) serve aggiornare
i pin qui sotto (nome file + sha256 dal db https://repo.msys2.org/mingw/ucrt64/ucrt64.db) e riprovare i test.
La -Cache conserva i pacchetti gia' scaricati: copiarla su Wasabi/NAS evita il problema.
#>
param(
[Parameter(Mandatory = $true)][string]$Destinazione,
[string]$Cache = (Join-Path $env:LOCALAPPDATA 'Tielogic\tmf-gtk-cache'),
[switch]$Forza
)
$ErrorActionPreference = 'Stop'
# PS 5.1 usa TLS 1.0 di default: repo.msys2.org vuole 1.2 (cosa/perche')
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$Base = 'https://repo.msys2.org/mingw/ucrt64/'
# Pin: pacchetto, file, sha256 (dal db ucrt64 di MSYS2 del 2026-10-17), licenza dichiarata.
$Pacchetti = @(
@{ N='brotli'; F='mingw-w64-ucrt-x86_64-brotli-1.2.0-1-any.pkg.tar.zst'; H='9cc89665496ea504751476eafba15c71c554aaf3004babc2004f7c07ffd60514'; L='MIT' }
@{ N='bzip2'; F='mingw-w64-ucrt-x86_64-bzip2-1.0.8-4-any.pkg.tar.zst'; H='f03a2174034ddd2d96cecd34f617c5f8e2ef86c812b8d2bb3b8875257f2c8bfa'; L='bzip2 (BSD)' }
@{ N='expat'; F='mingw-w64-ucrt-x86_64-expat-2.9.0-1-any.pkg.tar.zst'; H='f52364dc874009316e1ee1fe46ee89784594b80d10cbcc1cc856945d575308e0'; L='MIT' }
@{ N='fontconfig'; F='mingw-w64-ucrt-x86_64-fontconfig-2.18.3-1-any.pkg.tar.zst'; H='d0b364f3ee37e3b6f6c136688ce8fa909bb6d8a69df7e08de1aa40870d9e1d60'; L='fontconfig (MIT-like)' }
@{ N='freetype'; F='mingw-w64-ucrt-x86_64-freetype-2.15.0-1-any.pkg.tar.zst'; H='a9a691f71d1a553099c54ca666e611abfb2b4ff08dae24f2a5af72e22a7e5feb'; L='GPL-2.0+ OR FTL' }
@{ N='fribidi'; F='mingw-w64-ucrt-x86_64-fribidi-1.0.17-1-any.pkg.tar.zst'; H='3581ee03cf83a9bb0e98bf688c4f532a29553500e317532d90746de295b0f8a8'; L='LGPL-2.1+' }
@{ N='gettext-runtime'; F='mingw-w64-ucrt-x86_64-gettext-runtime-1.0-1-any.pkg.tar.zst'; H='ba693dda4ac375af76ce481ff3a6e7481286546cc7dc6d56c7021dae34084157'; L='LGPL-2.1+ (libintl)' }
@{ N='glib2'; F='mingw-w64-ucrt-x86_64-glib2-2.90.1-1-any.pkg.tar.zst'; H='9a7826349279bcca0c935b01880d812be9de9609aef2205be68fd7a6128c1695'; L='LGPL-2.1+' }
@{ N='graphite2'; F='mingw-w64-ucrt-x86_64-graphite2-1.3.15-1-any.pkg.tar.zst'; H='34ca1f88bff10ef182a6b1c8e5622d5029f13b05748e361af13b552b422ea849'; L='LGPL-2.1+' }
@{ N='harfbuzz'; F='mingw-w64-ucrt-x86_64-harfbuzz-14.6.0-1-any.pkg.tar.zst'; H='8781d486c864bf91f482768cc5bd7267e49331636583d9f855af3974e6378e69'; L='MIT' }
@{ N='libdatrie'; F='mingw-w64-ucrt-x86_64-libdatrie-0.2.14-1-any.pkg.tar.zst'; H='ea92777801ad325278193a362967540752ae37035016df2f6bcee2d89928a195'; L='LGPL' }
@{ N='libffi'; F='mingw-w64-ucrt-x86_64-libffi-3.8.0-1-any.pkg.tar.zst'; H='99ad12f4ecfa00a889ef9c5c0188592368a5bac109d0c508ce4e04f29ea95a76'; L='MIT' }
@{ N='libgcc'; F='mingw-w64-ucrt-x86_64-libgcc-16.2.0-6-any.pkg.tar.zst'; H='80b2497f019977dd9528b1abc8621b1328a0115ab1f724047ccbb56bf323f11d'; L='GPL-3.0+ con GCC runtime exception' }
@{ N='libiconv'; F='mingw-w64-ucrt-x86_64-libiconv-1.19-1-any.pkg.tar.zst'; H='9a500f38c2b91808741c62fae746b3e9110b33a1ecf5c30fa0c66dbedddf7e16'; L='LGPL-2.1+' }
@{ N='libpng'; F='mingw-w64-ucrt-x86_64-libpng-1.6.59-1-any.pkg.tar.zst'; H='7f8448e06eceba8f8ce130390addea49f767bdf8f6b7e7eede8ca6d595e3d3a8'; L='libpng (permissiva)' }
@{ N='libstdc++'; F='mingw-w64-ucrt-x86_64-libstdc++-16.2.0-6-any.pkg.tar.zst'; H='33c5e58a85a538cf843a8672991cce591f0231d66da3b66a57629712d64be17f'; L='GPL-3.0+ con GCC runtime exception' }
@{ N='libthai'; F='mingw-w64-ucrt-x86_64-libthai-0.1.30-1-any.pkg.tar.zst'; H='c90ebbca3e7b4c34f4b3ffa501ba8d00cdaf6340cff7db3c46d79d5e01ef207c'; L='LGPL-2.1+' }
@{ N='libwinpthread'; F='mingw-w64-ucrt-x86_64-libwinpthread-14.0.0.r426.g4564ee4b5-2-any.pkg.tar.zst'; H='a02f5e09d666d3be657c78c86fe67c975d04fe33b5211cd5ff1b23a91c118524'; L='MIT AND BSD-3-Clause' }
@{ N='pcre2'; F='mingw-w64-ucrt-x86_64-pcre2-10.49-1-any.pkg.tar.zst'; H='63172179db724ac71e2439a7981134c1bcab8049bdddfd349b32b8f76ed52d1e'; L='BSD-3-Clause' }
@{ N='pango'; F='mingw-w64-ucrt-x86_64-pango-1.58.2-1-any.pkg.tar.zst'; H='8d86e4a018ff139b010bf357fa1c9f9030fa32ee195c54ed7e89d8ea701851c8'; L='LGPL-2.1' }
@{ N='zlib-ng-compat'; F='mingw-w64-ucrt-x86_64-zlib-ng-compat-2.3.3-2-any.pkg.tar.zst'; H='b3ca6b437fb154a0fa26652002cdda591498ee4a0e08d91cc653c3702d090f25'; L='Zlib' }
)
# DLL che WeasyPrint carica (6 radici) piu' la loro chiusura di dipendenze (calcolata con pefile il 2026-10-17).
$Dll = @(
'libbrotlicommon.dll','libbrotlidec.dll','libbz2-1.dll','libdatrie-1.dll','libexpat-1.dll','libffi-8.dll',
'libfontconfig-1.dll','libfreetype-6.dll','libfribidi-0.dll','libgcc_s_seh-1.dll','libgio-2.0-0.dll',
'libglib-2.0-0.dll','libgmodule-2.0-0.dll','libgobject-2.0-0.dll','libgraphite2.dll','libharfbuzz-0.dll',
'libharfbuzz-subset-0.dll','libiconv-2.dll','libintl-8.dll','libpango-1.0-0.dll','libpangoft2-1.0-0.dll',
'libpcre2-8-0.dll','libpng16-16.dll','libstdc++-6.dll','libthai-0.dll','libwinpthread-1.dll','zlib1.dll'
)
function Scrivi([string]$m) { Write-Host ("[prendi-gtk] " + $m) }
# Impronta dei pin: se cambia, la cartella gia' pronta non e' piu' valida.
$pinTesto = (($Pacchetti | ForEach-Object { $_.F + ':' + $_.H }) -join "`n") + "`n" + ($Dll -join ',')
$sha = [Security.Cryptography.SHA256]::Create()
$impronta = -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($pinTesto)) | ForEach-Object { $_.ToString('x2') })
$marker = Join-Path $Destinazione '.gtk-pin'
$dllMancanti = @($Dll | Where-Object { -not (Test-Path (Join-Path $Destinazione $_)) })
if (-not $Forza -and (Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $impronta) -and $dllMancanti.Count -eq 0) {
Scrivi "gia' pronto in $Destinazione (pin $($impronta.Substring(0,12)))"
exit 0
}
# --- estrazione .zst: tar.exe di Windows se sa di zstd, altrimenti Python zstandard via uv ---
$tar = Join-Path $env:SystemRoot 'System32\tar.exe'
$tarZstd = $false
if (Test-Path $tar) { $tarZstd = ((& $tar --version) -join ' ') -match 'zstd' }
function Estrai([string]$pkg, [string]$dest) {
if ($tarZstd) {
& $tar -xf $pkg -C $dest
if ($LASTEXITCODE -ne 0) { throw "tar.exe ha fallito su $pkg" }
} else {
$py = "import sys,tarfile,zstandard`nd=zstandard.ZstdDecompressor()`nwith open(sys.argv[1],'rb') as f, d.stream_reader(f) as r:`n tarfile.open(fileobj=r,mode='r|').extractall(sys.argv[2])"
$tmpPy = Join-Path ([IO.Path]::GetTempPath()) ('estrai-zst-' + [guid]::NewGuid().ToString('N') + '.py')
Set-Content -Path $tmpPy -Value $py -Encoding ASCII
try {
& uv run --quiet --with zstandard python -I $tmpPy $pkg $dest
if ($LASTEXITCODE -ne 0) { throw "estrazione zstd via uv fallita su $pkg" }
}
finally { Remove-Item $tmpPy -ErrorAction SilentlyContinue }
}
}
New-Item -ItemType Directory -Force -Path $Cache | Out-Null
# Lavoro in una cartella temporanea, poi sposto: un errore a meta' non lascia mezza cartella in -Destinazione.
$tmp = Join-Path ([IO.Path]::GetTempPath()) ('tmf-gtk-' + [guid]::NewGuid().ToString('N'))
$stage = Join-Path $tmp 'stage'
New-Item -ItemType Directory -Force -Path $stage | Out-Null
try {
foreach ($p in $Pacchetti) {
$file = Join-Path $Cache $p.F
$ok = $false
if (Test-Path $file) {
$ok = ((Get-FileHash $file -Algorithm SHA256).Hash.ToLower() -eq $p.H)
if (-not $ok) { Scrivi "cache corrotta, riscarico $($p.F)"; Remove-Item $file -Force }
}
if (-not $ok) {
Scrivi "scarico $($p.F)"
$parz = $file + '.parz'
# il '+' di libstdc++ va codificato nell'URL
Invoke-WebRequest -UseBasicParsing -Uri ($Base + [Uri]::EscapeDataString($p.F)) -OutFile $parz
$h = (Get-FileHash $parz -Algorithm SHA256).Hash.ToLower()
if ($h -ne $p.H) { Remove-Item $parz -Force; throw "sha256 diverso per $($p.F): atteso $($p.H), trovato $h" }
Move-Item $parz $file -Force
}
$ex = Join-Path $tmp $p.N
New-Item -ItemType Directory -Force -Path $ex | Out-Null
Estrai $file $ex
$bin = Join-Path $ex 'ucrt64\bin'
if (Test-Path $bin) { Get-ChildItem $bin -Filter *.dll | Copy-Item -Destination $stage -Force }
# fontconfig: senza etc\fonts WeasyPrint non trova i font (errore "Cannot load default config file")
if ($p.N -eq 'fontconfig') { Copy-Item (Join-Path $ex 'ucrt64\etc\fonts') (Join-Path $stage 'etc\fonts') -Recurse -Force }
# licenze: le DLL LGPL si ridistribuiscono con il testo della licenza
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'licenze') | Out-Null
$lic = Join-Path $ex 'ucrt64\share\licenses'
if (Test-Path $lic) { Copy-Item $lic (Join-Path $stage ('licenze\' + $p.N)) -Recurse -Force }
Set-Content -Path (Join-Path $stage ('licenze\' + $p.N + '.txt')) -Value ($p.F + "`nlicenza: " + $p.L + "`nsorgente: https://packages.msys2.org/") -Encoding UTF8
}
# Tengo solo le DLL in whitelist: le altre DLL dei pacchetti non servono.
Get-ChildItem $stage -Filter *.dll | Where-Object { $Dll -notcontains $_.Name } | Remove-Item -Force
$mancanti = @($Dll | Where-Object { -not (Test-Path (Join-Path $stage $_)) })
if ($mancanti.Count -gt 0) { throw "DLL attese non trovate: $($mancanti -join ', ')" }
Set-Content -Path (Join-Path $stage '.gtk-pin') -Value $impronta -Encoding ASCII
if (Test-Path $Destinazione) { Get-ChildItem $Destinazione -Force | Remove-Item -Recurse -Force }
New-Item -ItemType Directory -Force -Path $Destinazione | Out-Null
Get-ChildItem $stage -Force | Move-Item -Destination $Destinazione -Force
Scrivi "pronto: $($Dll.Count) DLL in $Destinazione"
}
finally {
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
+53
View File
@@ -0,0 +1,53 @@
<#
Cosa fa: avvia TieMeasureFlow DA SORGENTE su una macchina di sviluppo Windows, senza la build
Nuitka (che richiede ~20 minuti): stessi quattro server e stesse porte dell'exe (8780 web,
8000 backend, 8100 visione, 8765 camera), stessa cartella dati %PROGRAMDATA%\Tielogic\TieMeasureFlow.
Prima dell'avvio: ambiente Python 3.13 in build\venv313 (uv sync, solo con -Sincronizza o se manca),
tailwind.css e traduzioni .mo ricompilati, DLL di WeasyPrint in build\gtk (scaricate con
pdf\prendi-gtk.ps1 se mancano).
Come si usa (dalla radice del repo):
powershell -ExecutionPolicy Bypass -File packaging\windows\sviluppo.ps1 [-Sincronizza] [-Ascolto 0.0.0.0] [-EscludiCamere OECS]
Dopo una modifica: Ctrl+C e rilancio (pochi secondi). Template e JS non richiedono altro;
classi Tailwind nuove e msgid nuovi vengono ricompilati a ogni avvio.
Dipende da: uv, npx (Node), la licenza tiemeasureflow di QUESTA macchina in
%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json (impronta: avvio.py --impronta).
#>
param(
[switch]$Sincronizza, # forza uv sync (dipendenze cambiate)
[string]$Ascolto = '127.0.0.1', # indirizzo di ascolto della UI web ($Host e' riservato in PowerShell)
[string]$EscludiCamere = '' # backend nativi da saltare (TieVision-Core#339): OECS se c'e' il runtime OECS ma in fiera si usa GenTL
)
$ErrorActionPreference = 'Stop'
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313' # stesso ambiente della build
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
if ($Sincronizza -or -not (Test-Path $py)) {
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
}
# CSS e traduzioni: non sono nel repo, come in build.ps1
$flask = Join-Path $radice 'src\frontend\flask_app'
Push-Location $flask
try {
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') -q compile -d translations
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
} finally { Pop-Location }
# DLL di WeasyPrint fuori dalla radice (gtk\ nella radice non e' ignorata da git): build\gtk
$gtk = Join-Path $radice 'build\gtk'
if (-not (Test-Path (Join-Path $gtk '*.dll'))) {
powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1') -Destinazione $gtk
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
}
$env:WEASYPRINT_DLL_DIRECTORIES = $gtk
if ($EscludiCamere) { $env:VS_CAMERA_ESCLUDI = $EscludiCamere } # #339: stesso percorso camera del PC fiera
Set-Location $radice
& $py (Join-Path $PSScriptRoot 'avvio.py') --senza-browser --senza-finestre --host $Ascolto # errori in console, niente MessageBox
exit $LASTEXITCODE
+347
View File
@@ -0,0 +1,347 @@
"""Test di packaging/windows/avvio.py (TieMeasureFlow#76): licenza firmata con la chiave di prova
del contratto v1, nessun server vero (avvia_server e migrazioni sono sostituiti).
Si lanciano con l'ambiente 3.13 del pacchetto (serve visionsuite.license):
UV_PROJECT_ENVIRONMENT=build/venv313 uv run --python 3.13 --frozen --extra server --extra client \
--extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q
"""
from __future__ import annotations
import base64
import functools
import importlib.util
import json
import os
import sqlite3
import sys
import types
from datetime import date, timedelta
from pathlib import Path
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
RADICE = Path(__file__).resolve().parents[3]
# vettore di prova del contratto licenza v1 (chiave finta, solo test)
SEED_TEST = base64.b64decode("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
PUBBLICA_TEST = base64.b64decode("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
IMPRONTA = "ab" * 32
def _firma(dati: dict) -> str:
# stessa canonicalizzazione del contratto (byte firmati)
byte = json.dumps(dati, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
return base64.b64encode(Ed25519PrivateKey.from_private_bytes(SEED_TEST).sign(byte)).decode()
@pytest.fixture
def avvio(monkeypatch, tmp_path):
# avvio.py non e' in un pacchetto: lo si carica dal percorso
spec = importlib.util.spec_from_file_location("avvio_tmf", RADICE / "packaging/windows/avvio.py")
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
monkeypatch.setenv("PROGRAMDATA", str(tmp_path)) # ProgramData finta
monkeypatch.delenv("TIELOGIC_LICENZA", raising=False)
monkeypatch.setattr(os, "environ", os.environ.copy()) # le variabili impostate da avvio non escono dal test
monkeypatch.setattr(m.lic, "impronta", lambda: IMPRONTA)
# verifica reale (formato, firma, prodotto, macchina, scadenza) ma con la chiave di prova
verifica_reale = m.lic.verifica
monkeypatch.setattr(m.lic, "verifica", functools.partial(
verifica_reale, chiave_pubblica=PUBBLICA_TEST, impronta_attesa=IMPRONTA))
m.chiamate = []
monkeypatch.setattr(m, "avvia_server", lambda host, dati: m.chiamate.append((host, dati)) or [])
monkeypatch.setattr(m, "esegui_migrazioni", lambda: m.chiamate.append("migrazioni"))
m.aperti = []
monkeypatch.setattr(m.webbrowser, "open", lambda url: m.aperti.append(url))
monkeypatch.setattr(m, "_prepara_log", lambda dati=None: None)
monkeypatch.setattr(m, "_porta_aperta", lambda porta: False)
return m
def _scrivi_licenza(tmp_path: Path, **modifiche) -> Path:
dati = {"versione": 1, "prodotto": "tiemeasureflow", "cliente": "Prova Srl", "impronta": IMPRONTA,
"emessa": date.today().isoformat(), "scadenza": (date.today() + timedelta(days=30)).isoformat()}
dati.update(modifiche)
p = tmp_path / "Tielogic" / "licenze" / "tiemeasureflow.json"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps({"dati": dati, "firma": _firma(dati)}), encoding="utf-8")
return p
def test_licenza_mancante_non_avvia_e_scrive_impronta(avvio, capsys):
assert avvio.main(["--senza-finestre"]) == 1
assert avvio.chiamate == [] and avvio.aperti == []
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
err = capsys.readouterr().err
assert "Licenza non trovata" in err and IMPRONTA in err and "tiemeasureflow.json" in err
@pytest.mark.parametrize("modifica,testo", [
({"scadenza": "2020-01-01"}, "scaduta"),
({"impronta": "cd" * 32}, "altra macchina"),
({"prodotto": "tievision-studio"}, "altro prodotto"),
])
def test_licenza_non_valida_non_avvia(avvio, tmp_path, capsys, modifica, testo):
_scrivi_licenza(tmp_path, **modifica)
assert avvio.main(["--senza-finestre"]) == 1
assert avvio.chiamate == []
assert testo in capsys.readouterr().err
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
def test_licenza_manomessa_non_avvia(avvio, tmp_path, capsys):
p = _scrivi_licenza(tmp_path)
doc = json.loads(p.read_text(encoding="utf-8"))
doc["dati"]["cliente"] = "Altro Cliente" # dati cambiati dopo la firma
p.write_text(json.dumps(doc), encoding="utf-8")
assert avvio.main(["--senza-finestre"]) == 1
assert "Firma" in capsys.readouterr().err
def test_licenza_valida_prepara_dati_e_avvia(avvio, tmp_path):
_scrivi_licenza(tmp_path)
assert avvio.main(["--senza-finestre", "--senza-browser", "--host", "0.0.0.0"]) == 0
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
# prima le migrazioni, poi i server
assert avvio.chiamate == ["migrazioni", ("0.0.0.0", dati)]
assert (dati / "config.env").is_file() and (dati / "uploads").is_dir() and (dati / "camera").is_dir()
assert not avvio.percorso_impronta().exists() # con licenza valida non si scrive nulla
assert avvio.aperti == []
def test_variabili_del_contratto(avvio, tmp_path):
_scrivi_licenza(tmp_path)
avvio.main(["--senza-finestre", "--senza-browser"])
e = os.environ
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
assert e["APP_ENV"] == "production"
assert e["DB_URL"] == f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}" and "\\" not in e["DB_URL"]
assert e["UPLOAD_DIR"] == str(dati / "uploads") == e["VISION_RECIPES_ROOT"]
assert e["VISION_WORKER_URL"] == "http://127.0.0.1:8100"
assert e["API_SERVER_URL"] == "http://127.0.0.1:8000"
assert e["SESSION_COOKIE_SECURE"] == "0"
for k in ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD"):
assert len(e[k]) >= 32
def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path):
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
primo = avvio.carica_segreti(dati)
testo = (dati / "config.env").read_text(encoding="utf-8")
assert set(primo) == {"SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD", "STATION_CODE"}
assert len({primo[k] for k in avvio.SEGRETI}) == 4 # segreti distinti
assert primo["STATION_CODE"] == "ST-DEFAULT"
secondo = avvio.carica_segreti(dati)
assert secondo == primo and (dati / "config.env").read_text(encoding="utf-8") == testo
def test_config_env_completa_i_segreti_mancanti(avvio, tmp_path):
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
dati.mkdir(parents=True)
(dati / "config.env").write_text("# nota\nSETUP_PASSWORD=scelta-da-me\n", encoding="utf-8")
v = avvio.carica_segreti(dati)
assert v["SETUP_PASSWORD"] == "scelta-da-me" and len(v["SERVER_SECRET_KEY"]) >= 32
assert avvio.carica_segreti(dati) == v
def test_seconda_istanza_apre_solo_il_browser(avvio, tmp_path, monkeypatch):
_scrivi_licenza(tmp_path)
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
monkeypatch.setattr(avvio, "_e_tmf", lambda: True)
assert avvio.main(["--senza-finestre"]) == 0
assert avvio.aperti == ["http://127.0.0.1:8780/"] and avvio.chiamate == []
def test_porta_occupata_da_altro_programma(avvio, tmp_path, monkeypatch, capsys):
_scrivi_licenza(tmp_path)
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
monkeypatch.setattr(avvio, "_e_tmf", lambda: False)
assert avvio.main(["--senza-finestre"]) == 2
assert "8780" in capsys.readouterr().err and avvio.chiamate == []
def test_migrazione_fallita_esce_con_3(avvio, tmp_path, monkeypatch, capsys):
_scrivi_licenza(tmp_path)
def rotta():
raise RuntimeError("db bloccato")
monkeypatch.setattr(avvio, "esegui_migrazioni", rotta)
assert avvio.main(["--senza-finestre"]) == 3
assert "db bloccato" in capsys.readouterr().err
def test_impronta_si_stampa_senza_licenza(avvio, capsys):
assert avvio.main(["--impronta", "--senza-finestre"]) == 0
assert IMPRONTA in capsys.readouterr().out
def test_nessun_utente_rileva_il_primo_avvio(avvio, tmp_path):
db = tmp_path / "t.db"
assert avvio._nessun_utente(db) is False # file assente: nessuna conclusione
con = sqlite3.connect(db)
con.execute("CREATE TABLE users (id INTEGER)")
con.commit()
assert avvio._nessun_utente(db) is True
con.execute("INSERT INTO users VALUES (1)")
con.commit()
con.close()
assert avvio._nessun_utente(db) is False
def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(avvio, tmp_path):
_scrivi_licenza(tmp_path)
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
dati.mkdir(parents=True)
(dati / "config.env").write_text("STATION_CODE=ST-FIERA\nSTATION_SWITCH_ENABLED=1\nAPP_ENV=dev\n", encoding="utf-8")
avvio.main(["--senza-finestre", "--senza-browser"])
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
# --- TieMeasureFlow#82: strumenti nel pacchetto (nel binario non ci sono gli entry point) ---
def _modulo_strumenti(monkeypatch, moduli):
# finto _strumenti.py generato da build.ps1
finto = types.ModuleType("_strumenti")
finto.MODULI = tuple(moduli)
monkeypatch.setitem(sys.modules, "_strumenti", finto)
def test_registra_strumenti_da_sorgente_senza_modulo_generato(avvio, monkeypatch):
# _strumenti.py esiste solo dopo build.ps1: se manca (sorgente) bastano gli entry point
monkeypatch.setitem(sys.modules, "_strumenti", None) # None in sys.modules = ImportError all'import
from visionsuite.task.graph import registry
assert avvio.registra_strumenti() == []
assert len(registry()) > 0
def test_registra_strumenti_modulo_inesistente_non_solleva(avvio, monkeypatch):
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image", "visionsuite.tools.non_esiste"))
assert avvio.registra_strumenti() == ["visionsuite.tools.non_esiste"] # raccolto, non sollevato
def test_registra_strumenti_importa_i_moduli_elencati(avvio, monkeypatch):
# senza entry point (come nell'exe) i moduli di MODULI sono l'unica via: li si importa per nome
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image",))
visti = []
monkeypatch.setattr(avvio.importlib, "import_module", lambda nome: visti.append(nome))
assert avvio.registra_strumenti() == [] and visti == ["visionsuite.tools.image"]
def test_controlla_strumenti_senza_licenza_scrive_json(avvio, monkeypatch, capsys, tmp_path):
monkeypatch.setitem(sys.modules, "_strumenti", None)
# la licenza non deve essere nemmeno chiesta, e server e migrazioni non devono partire
def mai(*a, **k):
raise AssertionError("licenza, server o migrazioni chiamati da --controlla-strumenti")
monkeypatch.setattr(avvio.lic, "verifica", mai)
monkeypatch.setattr(avvio.lic, "cerca_licenza", mai)
monkeypatch.setattr(avvio, "avvia_server", mai)
monkeypatch.setattr(avvio, "esegui_migrazioni", mai)
assert avvio.main(["--controlla-strumenti", "--senza-finestre"]) == 0
dati = json.loads(capsys.readouterr().out)
assert dati["strumenti"] > 0 and dati["strumenti"] == len(dati["kind"])
assert dati["falliti"] == []
scritto = tmp_path / "Tielogic" / "TieMeasureFlow" / "log" / "controllo-strumenti.json"
assert json.loads(scritto.read_text(encoding="utf-8")) == dati
assert avvio.aperti == [] and not (tmp_path / "Tielogic" / "TieMeasureFlow" / "tmf.db").exists() # nessun DB
def test_controlla_strumenti_exit_3_se_un_modulo_fallisce(avvio, monkeypatch, capsys):
_modulo_strumenti(monkeypatch, ("visionsuite.tools.non_esiste",))
assert avvio.main(["--controlla-strumenti"]) == 3
assert json.loads(capsys.readouterr().out)["falliti"] == ["visionsuite.tools.non_esiste"]
def test_controlla_strumenti_exit_3_se_il_catalogo_e_vuoto(avvio, monkeypatch, capsys):
# niente strumenti registrati (es. entry point assenti e _strumenti vuoto): il controllo deve fallire
import visionsuite.task.graph as graph
monkeypatch.setattr(avvio, "registra_strumenti", lambda: [])
monkeypatch.setattr(graph, "registry", lambda: {})
assert avvio.main(["--controlla-strumenti"]) == 3
assert json.loads(capsys.readouterr().out)["strumenti"] == 0
def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path, monkeypatch):
# main() chiama registra_strumenti() dopo licenza e migrazioni e prima di avvia_server
_scrivi_licenza(tmp_path)
monkeypatch.setattr(avvio, "registra_strumenti", lambda: avvio.chiamate.append("strumenti") or [])
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
# ---------------------------------------------------------------- scalda camere (#108)
def _db_camere(percorso):
# #108: db minimo con le due tabelle lette da camere_da_scaldare
con = sqlite3.connect(percorso)
con.execute("CREATE TABLE stations (id INTEGER PRIMARY KEY, camera_url TEXT, camera_source TEXT)")
con.execute("CREATE TABLE recipe_tasks (id INTEGER PRIMARY KEY, acquisition_json TEXT)")
con.executemany("INSERT INTO stations (camera_url, camera_source) VALUES (?, ?)", [
("http://127.0.0.1:8765", "camera:601020"), # valida
(None, None), # stazione senza camera
("http://127.0.0.1:8765", "camera:folder"), # non apre hardware
])
con.executemany("INSERT INTO recipe_tasks (acquisition_json) VALUES (?)", [
(json.dumps({"camera_url": "http://127.0.0.1:8765", "camera_source": "camera:601020"}),), # duplicato
(json.dumps({"camera_url": "http://10.0.0.5:8765/", "camera_source": "camera:777"}),), # valida
("{rotto",), # JSON non valido
(None,),
])
con.commit()
con.close()
def test_camere_da_scaldare_filtra_e_deduplica(avvio, tmp_path):
db = tmp_path / "tmf.db"
_db_camere(db)
assert avvio.camere_da_scaldare(db) == [
("http://127.0.0.1:8765", "camera:601020"),
("http://10.0.0.5:8765/", "camera:777"),
]
def test_camere_da_scaldare_db_inesistente(avvio, tmp_path):
assert avvio.camere_da_scaldare(tmp_path / "non_esiste.db") == []
def test_scalda_camere_una_chiamata_per_coppia(avvio, tmp_path, monkeypatch):
db = tmp_path / "tmf.db"
_db_camere(db)
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
chiamate = []
class Risposta:
status = 200
def __enter__(self): return self
def __exit__(self, *a): return False
def finto_urlopen(richiesta, timeout=None):
chiamate.append((richiesta.full_url, json.loads(richiesta.data), richiesta.get_method(), timeout))
return Risposta()
monkeypatch.setattr(avvio.urllib.request, "urlopen", finto_urlopen)
avvio.scalda_camere(db)
corpo = {"trigger": "continuo", "timeout_ms": 5000}
assert chiamate == [
("http://127.0.0.1:8765/camera/grab", {"source": "camera:601020", **corpo}, "POST", 90),
("http://10.0.0.5:8765/camera/grab", {"source": "camera:777", **corpo}, "POST", 90),
]
def test_scalda_camere_eccezione_urlopen_non_esce(avvio, tmp_path, monkeypatch):
db = tmp_path / "tmf.db"
_db_camere(db)
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
tentativi = []
def urlopen_rotto(richiesta, timeout=None):
tentativi.append(richiesta.full_url)
raise OSError("camera irraggiungibile")
monkeypatch.setattr(avvio.urllib.request, "urlopen", urlopen_rotto)
avvio.scalda_camere(db) # non solleva e passa alla camera successiva
assert len(tentativi) == 2
+54 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "tiemeasureflow" name = "tiemeasureflow"
version = "2.0.0" version = "3.0.0"
description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations." description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations."
requires-python = ">=3.11" requires-python = ">=3.11"
authors = [ authors = [
@@ -53,6 +53,48 @@ dev = [
"coverage>=7.0.0", "coverage>=7.0.0",
] ]
# Vision runner (VisionSuite submodule): what src/vision/runner.py needs to
# execute a graph. vs-task/vs-measure/vs-pm2d require Python >=3.13 upstream;
# marker keeps the base 3.11 resolution untouched. numpy is declared
# explicitly - runner.py imports it directly, and it must not depend on
# being pulled in as a side effect of vs-pm2d's own dependencies.
vision = [
"visionsuite; python_version>='3.13'",
"vs-task; python_version>='3.13'",
"vs-measure; python_version>='3.13'",
"vs-pm2d; python_version>='3.13'",
# #46: tutti gli strumenti della suite (locate/MatchPattern e dxf/CompareDxf)
"vs-tools[locate,dxf]; python_version>='3.13'",
"numpy; python_version>='3.13'",
]
# Vision worker (src/vision_worker/): the FastAPI shell around the runner,
# on top of `vision`. Kept separate on purpose (Ruling R9) - VisionSuite's
# whole structure exists so a consumer installs only what it needs, and a
# later station agent embeds the same runner without being a web service.
# It must not drag fastapi/uvicorn onto shop-floor PCs.
vision-worker = [
"tiemeasureflow[vision]",
"fastapi>=0.110.0; python_version>='3.13'",
"uvicorn[standard]>=0.30.0; python_version>='3.13'",
"pillow>=10.0.0; python_version>='3.13'",
"python-multipart>=0.0.6; python_version>='3.13'",
]
# Pacchetto Windows (packaging/windows, TieMeasureFlow#76): solo per build.ps1, non per
# i container. Si usa con --python 3.13 insieme agli extra server, client e vision-worker.
[dependency-groups]
windows = [
"tzdata; sys_platform == 'win32'", # ZoneInfo("Europe/Rome") (backend e frontend): Windows non ha il database IANA
"aiosqlite>=0.20.0", # database SQLite su file (DB_URL, piano A)
"waitress>=3.0.0", # server WSGI del frontend Flask (gunicorn non gira su Windows)
"vs-camera[genicam]; python_version>='3.13'", # scripts/camera_service.py: camere GigE/GenTL (producer .cti del vendor)
"vs-license; python_version>='3.13'", # licenza Tielogic v1 (impronta macchina, firma Ed25519)
"nuitka>=2.5", # compilazione standalone
"ordered-set>=4.1", # richiesto da Nuitka per la modalita' standalone
"zstandard>=0.22", # compressione dei dati incorporati di Nuitka
]
[project.scripts] [project.scripts]
# Backend # Backend
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
@@ -70,6 +112,16 @@ packages = ["src/backend", "src/frontend"]
# Pin the resolver to the deps we declared; reproducible builds. # Pin the resolver to the deps we declared; reproducible builds.
package = false package = false
[tool.uv.sources]
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true } # #76 camera di stazione
vs-license = { path = "vendor/visionsuite/packages/vs-license", editable = true } # #76 licenza del pacchetto Windows
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests"] testpaths = ["src/backend/tests", "src/frontend/flask_app/tests", "src/vision/tests", "src/vision_worker/tests"]
+781
View File
@@ -0,0 +1,781 @@
"""Calibrazione a scacchiera dell'acquisitore (TieMeasureFlow, #83).
Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con
TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente
camera in un JSON (scrittura atomica).
#91: le calibrazioni stanno in un archivio UNICO, salvate con nome (classe Archivio);
una sorgente ne ha una attiva (attive.json) e il formato vecchio (una per sorgente)
si migra da solo.
#84: `calibra_scacchiera_densa(pixels, square_mm)` calibra da UNA foto di
scacchiera che riempie tutto il campo (ottica telecentrica): trova da sola la
griglia, costruisce la mappa di distorsione (DistortionMap) e il mm/px
dell'immagine raddrizzata. L'Archivio salva la mappa accanto al JSON (.npz) e
`raddrizza()` la applica ai frame.
Uso: importato da scripts/camera_service.py (rotte /camera/calibrate e
/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e
`calibra_scacchiera_densa(gray, square_mm)` sono logica pura, senza FastAPI,
e si provano da sole.
Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro (no scipy).
"""
from __future__ import annotations
import base64
import hashlib
import json
import math
import os
import re
import tempfile
import threading
import unicodedata # #91 caratteri di controllo nei nomi
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import NamedTuple
import cv2
import numpy as np
from visionsuite.calibration import calibrate_planar, find_chessboard
from visionsuite.calibration.distortion_map import DistortionMap # #84 mappa densa
from visionsuite.core.image import Image
class CalibrazioneError(ValueError):
"""Parametri invalidi o scacchiera non trovata: input sbagliato, non un guasto (422)."""
def _gray_u8(pixels: np.ndarray) -> np.ndarray:
"""Immagine -> grigio uint8 2D."""
px = np.asarray(pixels)
if px.ndim == 3:
px = cv2.cvtColor(px, cv2.COLOR_BGRA2GRAY if px.shape[2] == 4 else cv2.COLOR_BGR2GRAY)
if px.dtype != np.uint8:
# float/uint16 -> scala sul massimo, come fa vs-core
m = float(px.max()) if px.size else 1.0
px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8)
return np.ascontiguousarray(px)
def calibra_scacchiera(pixels, cols: int, rows: int, square_mm: float) -> dict:
"""Scala (px/mm) da una foto di scacchiera piana. cols/rows = angoli INTERNI.
Ritorna {px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm,
image_width, image_height, scale_spread_pct, metodo, distorsione,
overlay_png (b64)}.
"""
if cols < 2 or rows < 2:
raise CalibrazioneError("cols e rows (angoli interni) devono essere almeno 2")
if not (square_mm > 0 and math.isfinite(square_mm)):
raise CalibrazioneError("square_mm deve essere > 0")
frame = _gray_u8(pixels)
image = Image(frame)
try:
image_pts, world_pts = find_chessboard(image, (cols, rows), square_mm)
except RuntimeError as exc:
# find_chessboard solleva RuntimeError quando la scacchiera non c'e'
raise CalibrazioneError(
f"scacchiera {cols}x{rows} non trovata nell'immagine: {exc}") from exc
model = calibrate_planar(image, image_pts, world_pts) # omografia pura
# Scala al centro della scacchiera (puo' non stare al centro immagine)
x0, y0 = image_pts.min(axis=0)
x1, y1 = image_pts.max(axis=0)
mm_per_px = model.pixel_size((x0 + x1) / 2.0, (y0 + y1) / 2.0)
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
raise CalibrazioneError("calibrazione degenere: scala non valida")
# Scarto massimo % della scala ai quattro angoli dell'area della scacchiera
# rispetto al centro: ottica telecentrica/piana ~ 0, prospettiva = grande.
spread = max(abs(model.pixel_size(x, y) - mm_per_px) / mm_per_px * 100.0
for x in (x0, x1) for y in (y0, y1))
# Overlay: angoli disegnati sull'immagine (PNG base64)
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
cv2.drawChessboardCorners(
bgr, (cols, rows), image_pts.reshape(-1, 1, 2).astype(np.float32), True)
ok, png = cv2.imencode(".png", bgr)
if not ok:
raise RuntimeError("PNG dell'overlay non codificabile")
h, w = frame.shape
return {
"px_per_mm": 1.0 / mm_per_px,
"mm_per_px": mm_per_px,
"rms_px": float(model.rms_reprojection),
"n_corners": int(len(image_pts)),
"cols": int(cols),
"rows": int(rows),
"square_mm": float(square_mm),
"image_width": int(w),
"image_height": int(h),
"scale_spread_pct": float(spread),
"metodo": "planare", # #84: il metodo con cols/rows resta quello di prima
"distorsione": False,
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
}
# ---- #84: calibrazione densa (scacchiera che riempie il campo, griglia non nota)
# #84: findChessboardCornersSBWithMeta (verificato su opencv 5.0.0) su una scacchiera
# grande quanto l'immagine trova solo frammenti (2064x1544 con quadrati da 40 px:
# 5x5, 28x6...). Per questo la griglia si cerca qui: sellini della Hessiana a piu'
# scale + crescita del reticolo; poi cornerSubPix.
_SIGME = (1.5, 2.2, 3.2, 4.5, 6.5, 9.0, 13.0) # scale dei sellini (passo ~ 5..120 px)
_MAX_CANDIDATI = 6000
def _candidati(gray: np.ndarray, sigma: float) -> np.ndarray:
"""Massimi locali del 'selliness' (-det Hessiana) = angoli di scacchiera, (n,2) x,y."""
b = cv2.GaussianBlur(gray.astype(np.float32), (0, 0), sigma)
ixx = cv2.Sobel(b, cv2.CV_32F, 2, 0, ksize=3)
iyy = cv2.Sobel(b, cv2.CV_32F, 0, 2, ksize=3)
ixy = cv2.Sobel(b, cv2.CV_32F, 1, 1, ksize=3)
r = ixy * ixy - ixx * iyy # >0 solo dove l'immagine ha un sellino
massimo = float(r.max())
if massimo <= 0:
return np.zeros((0, 2))
k = 2 * int(math.ceil(1.5 * sigma)) + 1
picchi = (r >= cv2.dilate(r, np.ones((k, k), np.uint8))) & (r > 0.1 * massimo)
ys, xs = np.nonzero(picchi)
if len(xs) > _MAX_CANDIDATI: # tiene i piu' forti
forti = np.argsort(r[ys, xs])[::-1][:_MAX_CANDIDATI]
ys, xs = ys[forti], xs[forti]
return np.stack([xs, ys], axis=1).astype(np.float64)
def _e_angolo_x(lisc: np.ndarray, delta: float, p: np.ndarray, u: np.ndarray, v: np.ndarray) -> bool:
"""True se attorno a p i quattro quadranti alternano chiaro/scuro (vero angolo X).
Scarta le giunzioni a L/T al bordo della scacchiera (3 quadranti uguali), che
il selliness da solo accetta e che spostano la griglia.
"""
h, w = lisc.shape
campioni = []
for q in (0.25 * (u + v), -0.25 * (u + v), 0.25 * (u - v), -0.25 * (u - v)):
x, y = int(round(p[0] + q[0])), int(round(p[1] + q[1]))
if not (0 <= x < w and 0 <= y < h):
return False
campioni.append(float(lisc[y, x]))
s1, s2, t1, t2 = campioni
return (min(s1, s2) > max(t1, t2) + delta) or (max(s1, s2) < min(t1, t2) - delta)
def _cresci_reticolo(pts: np.ndarray, centro: tuple[float, float],
lisc: np.ndarray, delta: float) -> dict | None:
"""Assegna un indice (riga, colonna) ai candidati che stanno su un reticolo.
Parte dal candidato piu' vicino al centro, ricava i due vettori di passo dai
vicini e cresce a macchia d'olio: la base si aggiorna a ogni passo, quindi
segue una distorsione lenta. Ritorna {(i, j): indice candidato} o None.
"""
if len(pts) < 9:
return None
seme = int(np.argmin(np.hypot(pts[:, 0] - centro[0], pts[:, 1] - centro[1])))
d = np.hypot(pts[:, 0] - pts[seme, 0], pts[:, 1] - pts[seme, 1])
vicini = np.argsort(d)[1:9]
u = pts[vicini[0]] - pts[seme]
lu = float(np.hypot(*u))
v = None
for k in vicini[1:]:
w = pts[k] - pts[seme]
lw = float(np.hypot(*w))
# il secondo vettore: quasi ortogonale al primo e di lunghezza simile
if lu > 0 and lw > 0 and abs(float(u @ w)) / (lu * lw) < 0.4 and lw < 1.5 * lu:
v = w
break
if v is None or lu < 3:
return None
posto = {(0, 0): seme}
indice = {seme: (0, 0)}
basi = {seme: (u, v)}
coda = deque([seme])
while coda:
a = coda.popleft()
i, j = indice[a]
u, v = basi[a]
tol = 0.3 * min(float(np.hypot(*u)), float(np.hypot(*v)))
for di, dj, vec in ((0, 1, u), (0, -1, -u), (1, 0, v), (-1, 0, -v)):
if (i + di, j + dj) in posto:
continue
prev = pts[a] + vec
dist = np.hypot(pts[:, 0] - prev[0], pts[:, 1] - prev[1])
b = int(np.argmin(dist))
if dist[b] > tol or b in indice:
continue
if not _e_angolo_x(lisc, delta, pts[b], u, v): # niente L/T del bordo
continue
posto[(i + di, j + dj)] = b
indice[b] = (i + di, j + dj)
reale = pts[b] - pts[a]
basi[b] = (reale * dj, v) if dj else (u, reale * di) # base locale aggiornata
coda.append(b)
return posto
def _rettangolo_pieno(posto: dict) -> tuple[int, int, int, int] | None:
"""Il rettangolo di indici tutto occupato di area massima: (i0, j0, righe, colonne)."""
if not posto:
return None
i_min = min(i for i, _ in posto)
j_min = min(j for _, j in posto)
nr = max(i for i, _ in posto) - i_min + 1
nc = max(j for _, j in posto) - j_min + 1
m = np.zeros((nr, nc), bool)
for (i, j) in posto:
m[i - i_min, j - j_min] = True
migliore = (0, 0, 0, 0)
alt = np.zeros(nc, int)
for r in range(nr): # istogramma delle altezze + pila, O(righe*colonne)
alt = np.where(m[r], alt + 1, 0)
pila: list[int] = []
for c in range(nc + 1):
h = int(alt[c]) if c < nc else 0
while pila and alt[pila[-1]] >= h:
altezza = int(alt[pila.pop()])
sx = pila[-1] + 1 if pila else 0
larg = c - sx
if altezza * larg > migliore[2] * migliore[3]:
migliore = (r - altezza + 1 + i_min, sx + j_min, altezza, larg)
pila.append(c)
return migliore if migliore[2] * migliore[3] else None
def _orienta(g: np.ndarray) -> np.ndarray:
"""Riordina (nr,nc,2) con colonne verso destra e righe verso il basso."""
a = (g[:, -1] - g[:, 0]).mean(axis=0) # direzione dell'indice di colonna
b = (g[-1, :] - g[0, :]).mean(axis=0) # direzione dell'indice di riga
if abs(b[0]) > abs(a[0]): # assi scambiati (scacchiera ruotata di ~90 gradi)
g = g.transpose(1, 0, 2)
a, b = b, a
if a[0] < 0:
g = g[:, ::-1]
if b[1] < 0:
g = g[::-1]
return np.ascontiguousarray(g)
def _passo_mediano(g: np.ndarray) -> float:
"""Distanza mediana (px) fra corner adiacenti."""
passi = np.concatenate([np.diff(g, axis=1).reshape(-1, 2), np.diff(g, axis=0).reshape(-1, 2)])
return float(np.median(np.hypot(passi[:, 0], passi[:, 1])))
def _raffina(img: np.ndarray, pts: np.ndarray, passo: float) -> np.ndarray:
"""cornerSubPix su (n,2) con finestra ~0.3 passo, ristretta per chi sta vicino al bordo.
Una finestra che esce dall'immagine vede il bordo replicato e sposta il corner
di ~1 px (visto sull'ultima colonna di una scacchiera a 14 px dal bordo): per
questi punti si usa la finestra piu' grande che sta dentro; sotto 3 px resta
la posizione grezza.
"""
h, w = img.shape
out = pts.astype(np.float32).copy()
win = int(min(20, max(3, 0.3 * passo)))
dentro = np.minimum.reduce([out[:, 0], w - 1 - out[:, 0], out[:, 1], h - 1 - out[:, 1]])
per_punto = np.minimum(win, np.floor(dentro).astype(int) - 2)
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 40, 0.001)
for wi in np.unique(per_punto[per_punto >= 3]):
sel = per_punto == wi
out[sel] = cv2.cornerSubPix(img, out[sel].reshape(-1, 1, 2).copy(),
(int(wi), int(wi)), (-1, -1), crit).reshape(-1, 2)
return out
def _trova_griglia(frame: np.ndarray) -> np.ndarray:
"""Griglia di angoli (n_righe, n_colonne, 2) x,y, in ordine di riga dall'alto."""
h, w = frame.shape
migliore = None
lisc = cv2.GaussianBlur(frame.astype(np.float32), (0, 0), 1.0)
p5, p95 = np.percentile(frame, (5, 95))
delta = 0.25 * float(p95 - p5) # contrasto minimo fra quadranti opposti
for sigma in _SIGME:
pts = _candidati(frame, sigma)
posto = _cresci_reticolo(pts, (w / 2.0, h / 2.0), lisc, delta)
rett = _rettangolo_pieno(posto) if posto else None
if rett is None:
continue
i0, j0, nr, nc = rett
if nr < 3 or nc < 3:
continue
if migliore is not None and nr * nc < 0.5 * migliore[0] * migliore[1]:
break # oltre il picco: le scale piu' grosse fondono gli angoli
if migliore is None or nr * nc > migliore[0] * migliore[1]:
g = np.array([[pts[posto[(i0 + i, j0 + j)]] for j in range(nc)]
for i in range(nr)])
migliore = (nr, nc, g)
if migliore is None:
raise CalibrazioneError(
"griglia della scacchiera non trovata: serve una scacchiera nitida "
"che riempia il campo")
g = _orienta(migliore[2])
nr, nc, _ = g.shape
passo = _passo_mediano(g)
# sub-pixel: finestra proporzionale al passo, abbastanza piccola da non vedere i vicini
grezzi = g.reshape(-1, 2).astype(np.float32)
raff = _raffina(frame, grezzi, passo)
spostati = np.hypot(*(raff - grezzi).T) > 0.3 * passo
raff[spostati] = grezzi[spostati] # un raffinamento che scappa e' peggio del grezzo
return raff.reshape(nr, nc, 2).astype(np.float64)
def _estrapola(g: np.ndarray, k: int, prima: bool) -> np.ndarray:
"""Aggiunge k colonne (asse 1) per estrapolazione lineare, prima o dopo."""
if k <= 0:
return g
if prima:
passo = g[:, 0] - g[:, 1]
extra = np.stack([g[:, 0] + m * passo for m in range(k, 0, -1)], axis=1)
return np.concatenate([extra, g], axis=1)
passo = g[:, -1] - g[:, -2]
extra = np.stack([g[:, -1] + m * passo for m in range(1, k + 1)], axis=1)
return np.concatenate([g, extra], axis=1)
def _estendi_asse(g: np.ndarray, lungh: int, c: int, passo: float,
grid_step: int) -> tuple[np.ndarray, int]:
"""Estende la griglia (asse 1) fino a coprire l'immagine; ritorna (griglia, k_prima).
`c` e' la coordinata (0 = x, 1 = y) e `lungh` la dimensione dell'immagine su
quell'asse. `from_grid` (crop_domain=False) tiene costante la mappa fuori
dalla griglia: senza anelli in piu' i bordi dell'immagine raddrizzata
sarebbero sbavati.
"""
margine = max(0.0, float(g[:, 0, c].min()))
k_pre = int(math.ceil(margine / passo)) + 1
g = _estrapola(g, k_pre, True)
offset = int(g[0, 0, c]) # lo stesso int() che usa from_grid
# colonne totali perche' l'ultimo nodo, a passo grid_step, superi il bordo
servono = int(math.ceil((lungh - 1 - offset) / grid_step)) + 2
g = _estrapola(g, max(0, servono - g.shape[1]), False)
return g, k_pre
class MappaPronta(NamedTuple):
"""Mappa gia' convertita in virgola fissa per un cv2.remap veloce (#84)."""
map1: np.ndarray
map2: np.ndarray
pixel_size: float
shape: tuple # (altezza, larghezza) dei frame per cui vale
def _prepara_mappa(mappa: DistortionMap) -> MappaPronta:
# CV_16SC2: remap bilineare in virgola fissa, piu' veloce di float32 su 5 MP
m1, m2 = cv2.convertMaps(mappa.map_col, mappa.map_row, cv2.CV_16SC2)
return MappaPronta(m1, m2, float(mappa.pixel_size), mappa.shape)
def raddrizza(frame: np.ndarray, mappa: MappaPronta) -> np.ndarray | None:
"""Frame raddrizzato, o None se le dimensioni non sono quelle della calibrazione."""
if frame.shape[:2] != tuple(mappa.shape):
return None
return cv2.remap(frame, mappa.map1, mappa.map2, cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
def calibra_scacchiera_densa(pixels, square_mm: float) -> tuple[dict, DistortionMap]:
"""Calibra da UNA foto di scacchiera densa (riempie il campo). Input: solo square_mm.
Ritorna (risultato, mappa). Risultato: {px_per_mm, mm_per_px (dell'immagine
RADDRIZZATA), rms_px, residuo_rms_px, residuo_max_px, n_corners, cols, rows,
square_mm, image_width, image_height, scale_spread_pct (0: la mappa uniforma
la scala), copertura_pct, metodo, distorsione, overlay_png (b64)}.
"""
if not (square_mm > 0 and math.isfinite(square_mm)):
raise CalibrazioneError("square_mm deve essere > 0")
frame = _gray_u8(pixels)
h, w = frame.shape
g = _trova_griglia(frame)
nr, nc, _ = g.shape
passo = _passo_mediano(g)
grid_step = int(round(passo))
if grid_step < 2:
raise CalibrazioneError("calibrazione degenere: passo della griglia troppo piccolo")
# anelli in piu' ai bordi (x e poi y), poi la mappa dai punti estesi
e, kl = _estendi_asse(g, w, 0, passo, grid_step)
et, kt = _estendi_asse(e.transpose(1, 0, 2), h, 1, passo, grid_step)
e = et.transpose(1, 0, 2)
try:
mappa = DistortionMap.from_grid(
e[..., 0].ravel(), e[..., 1].ravel(), e.shape[1], e.shape[0],
image_size=(w, h), grid_step=grid_step, crop_domain=False,
grid_step_measure=square_mm)
except ValueError as exc:
raise CalibrazioneError(f"mappa di distorsione non costruibile: {exc}") from exc
mm_per_px = float(mappa.pixel_size)
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
raise CalibrazioneError("calibrazione degenere: scala non valida")
# qualita': si raddrizza la stessa foto e si rilegge ogni corner nel punto ideale
off_c, off_r = int(e[0, 0, 0]), int(e[0, 0, 1])
ideali = np.array([[(off_c + (kl + j) * grid_step, off_r + (kt + i) * grid_step)
for j in range(nc)] for i in range(nr)], np.float32).reshape(-1, 1, 2)
rett = cv2.remap(frame, mappa.map_col, mappa.map_row, cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
# solo i corner ideali dentro l'immagine raddrizzata (cornerSubPix rifiuta quelli fuori)
xy = ideali.reshape(-1, 2)
dentro = (xy[:, 0] >= 1) & (xy[:, 0] <= w - 2) & (xy[:, 1] >= 1) & (xy[:, 1] <= h - 2)
if int(dentro.sum()) < 4:
raise CalibrazioneError("griglia raddrizzata fuori dall'immagine: calibrazione non verificabile")
ideali = ideali[dentro].reshape(-1, 2)
letti = _raffina(rett, ideali, grid_step)
scarto = (letti - ideali).astype(np.float64)
scarto -= np.median(scarto, axis=0) # int() dell'offset sposta tutto di una traslazione
dist = np.hypot(scarto[:, 0], scarto[:, 1])
residuo_max = float(dist.max())
residuo_rms = float(math.sqrt(float((dist ** 2).mean())))
# copertura: area del quadrilatero dei quattro angoli esterni / area immagine
quad = np.array([g[0, 0], g[0, -1], g[-1, -1], g[-1, 0]], np.float32)
copertura = float(cv2.contourArea(quad)) / float(w * h) * 100.0
# overlay: corner trovati sull'originale (ridotto a max 1600 px: 5 MP in PNG pesano)
f = min(1.0, 1600.0 / w)
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
pts = g.reshape(-1, 1, 2).astype(np.float32)
if f < 1.0:
bgr = cv2.resize(bgr, None, fx=f, fy=f, interpolation=cv2.INTER_AREA)
pts = pts * np.float32(f)
cv2.drawChessboardCorners(bgr, (nc, nr), pts, True)
ok, png = cv2.imencode(".png", bgr)
if not ok:
raise RuntimeError("PNG dell'overlay non codificabile")
return {
"px_per_mm": 1.0 / mm_per_px,
"mm_per_px": mm_per_px,
"rms_px": residuo_rms,
"residuo_rms_px": residuo_rms,
"residuo_max_px": residuo_max,
"n_corners": int(nr * nc),
"cols": int(nc),
"rows": int(nr),
"square_mm": float(square_mm),
"image_width": int(w),
"image_height": int(h),
"scale_spread_pct": 0.0,
"copertura_pct": copertura,
"metodo": "densa",
"distorsione": True,
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
}, mappa
def ora_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
class NomeNonValido(ValueError):
"""#91: nome di calibrazione vuoto, troppo lungo o con caratteri di controllo (400)."""
class NomeGiaUsato(ValueError):
"""#91: esiste gia' una calibrazione con questo nome (senza distinguere maiuscole) (409)."""
class NomeSconosciuto(KeyError):
"""#91: nessuna calibrazione con questo nome (404)."""
def pulisci_nome(nome) -> str:
"""#91: nome valido = strip, 1-60 caratteri, niente caratteri di controllo."""
if not isinstance(nome, str):
raise NomeNonValido("il nome della calibrazione deve essere un testo")
nome = nome.strip()
if not nome:
raise NomeNonValido("il nome della calibrazione è vuoto")
if len(nome) > 60:
raise NomeNonValido("il nome della calibrazione supera i 60 caratteri")
if any(unicodedata.category(c) == "Cc" for c in nome):
raise NomeNonValido("il nome della calibrazione contiene caratteri di controllo")
return nome
# #91: campi che l'elenco espone per ogni calibrazione (il resto resta nel JSON)
_CAMPI_ELENCO = ("nome", "origine", "image_width", "image_height", "px_per_mm",
"mm_per_px", "metodo", "square_mm", "calibrated_at")
class Archivio:
"""#91: archivio UNICO di calibrazioni con nome, in `cartella`.
Libreria: <cartella>/calibrazioni/<nome ripulito>-<sha1 del nome>.json (+ .npz se
densa, stesso nome). Attive: <cartella>/attive.json = {sorgente: nome}. Una
calibrazione non appartiene a una sorgente: la sorgente ne sceglie una (`attiva`).
#84: `leggi_mappa` tiene la mappa in memoria finche' il file non cambia.
Il formato di prima (un JSON per sorgente, campo "source", in <cartella>) si
migra da solo al costruttore (`migra`, idempotente).
"""
def __init__(self, cartella: Path | str):
self.cartella = Path(cartella)
self._cache: dict = {} # percorso .npz -> (mtime_ns, MappaPronta)
self._lock = threading.Lock()
self._lock_dati = threading.RLock() # #91: libreria e attive.json (leggi-modifica-scrivi)
self.migra()
# ---- percorsi
def _file(self, sorgente: str) -> Path:
# caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano
# (#91: oggi serve ai settaggi camera e al formato vecchio da migrare)
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", sorgente).strip("._") or "sorgente"
sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8]
return self.cartella / f"{pulito[:60]}-{sigla}.json"
@property
def _libreria(self) -> Path:
return self.cartella / "calibrazioni"
def _file_nome(self, nome: str) -> Path:
# #91: stesso schema di _file, ma l'hash e' sul nome senza maiuscole/minuscole
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", nome).strip("._") or "calibrazione"
sigla = hashlib.sha1(nome.casefold().encode("utf-8")).hexdigest()[:8]
return self._libreria / f"{pulito[:60]}-{sigla}.json"
def _file_mappa_nome(self, nome: str) -> Path:
return self._file_nome(nome).with_suffix(".npz") # #84 stesso nome del JSON
@property
def _file_attive(self) -> Path:
return self.cartella / "attive.json"
# ---- scrittura atomica
def _scrivi_json(self, destinazione: Path, dati: dict) -> None:
destinazione.parent.mkdir(parents=True, exist_ok=True)
# scrittura atomica: file temporaneo nella stessa cartella, poi replace
fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(dati, f, ensure_ascii=False, indent=2)
os.replace(tmp, destinazione)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _leggi_json(self, percorso: Path) -> dict | None:
try:
dati = json.loads(percorso.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None # assente o illeggibile
return dati if isinstance(dati, dict) else None
# ---- attive
def _leggi_attive(self) -> dict:
dati = self._leggi_json(self._file_attive) or {}
return {k: v for k, v in dati.items() if isinstance(k, str) and isinstance(v, str)}
def _scrivi_attive(self, attive: dict) -> None:
self._scrivi_json(self._file_attive, attive)
# ---- migrazione dal formato vecchio
def migra(self) -> None:
"""#91: ogni <cartella>/*.json con "source" (una per sorgente) entra in libreria.
Nome = valore di "source" (gia' preso: "<source> (2)", "(3)"...); il .npz
accanto segue; la sorgente resta calibrata con quella voce (attive.json).
Idempotente: il vecchio file sparisce, un secondo giro non trova nulla.
"""
if not self.cartella.is_dir():
return
with self._lock_dati:
for vecchio in sorted(self.cartella.glob("*.json")):
if vecchio.name == self._file_attive.name:
continue
dati = self._leggi_json(vecchio)
sorgente = dati.get("source") if dati else None
if not isinstance(sorgente, str) or not sorgente.strip():
continue
try:
self._migra_uno(vecchio, dati, sorgente)
except OSError:
continue # un file bloccato non ferma il servizio: riprova al prossimo avvio
def _migra_uno(self, vecchio: Path, dati: dict, sorgente: str) -> None:
base = "".join(c for c in sorgente if unicodedata.category(c) != "Cc").strip()[:60].strip() or "calibrazione"
nome, n = base, 1
while True:
esistente = self.leggi_nome(nome)
if esistente is None:
break
if (esistente.get("calibrated_at") == dati.get("calibrated_at")
and esistente.get("px_per_mm") == dati.get("px_per_mm")):
break # gia' copiata da un giro interrotto: si completa e basta
n += 1
suffisso = f" ({n})"
nome = base[:60 - len(suffisso)].rstrip() + suffisso
nuovo = {k: v for k, v in dati.items() if k != "source"}
nuovo["nome"] = nome
nuovo.setdefault("origine", sorgente) # l'origine vera non era registrata
vecchia_mappa = vecchio.with_suffix(".npz")
if vecchia_mappa.is_file(): # prima la mappa, poi il JSON che la dichiara
self._libreria.mkdir(parents=True, exist_ok=True)
os.replace(vecchia_mappa, self._file_mappa_nome(nome))
self._scrivi_json(self._file_nome(nome), nuovo)
attive = self._leggi_attive()
attive[sorgente.strip()] = nome
self._scrivi_attive(attive)
vecchio.unlink()
# ---- libreria
def leggi_nome(self, nome: str) -> dict | None:
"""#91: la calibrazione con questo nome (senza distinguere maiuscole), o None."""
try:
nome = pulisci_nome(nome)
except NomeNonValido:
return None
return self._leggi_json(self._file_nome(nome))
def _meta(self, dati: dict, attive: dict) -> dict:
meta = {k: dati.get(k) for k in _CAMPI_ELENCO}
chiave = str(dati.get("nome", "")).casefold()
meta["attiva_su"] = sorted(s for s, n in attive.items() if n.casefold() == chiave)
return meta
def elenco(self) -> list[dict]:
"""#91: metadati di tutte le calibrazioni, dalla piu' recente."""
with self._lock_dati:
attive = self._leggi_attive()
voci = []
for percorso in self._libreria.glob("*.json") if self._libreria.is_dir() else []:
dati = self._leggi_json(percorso)
if dati and isinstance(dati.get("nome"), str):
voci.append(self._meta(dati, attive))
voci.sort(key=lambda m: str(m.get("calibrated_at") or ""), reverse=True)
return voci
def metadati(self, nome: str) -> dict | None:
"""#91: i metadati di una voce (come in `elenco`), o None."""
dati = self.leggi_nome(nome)
return self._meta(dati, self._leggi_attive()) if dati else None
def salva_nome(self, nome: str, dati: dict, mappa: DistortionMap | None = None,
sovrascrivi: bool = False) -> dict:
"""#91: salva `dati` (e la mappa, se densa) con questo nome; ritorna i metadati."""
nome = pulisci_nome(nome)
destinazione = self._file_nome(nome)
with self._lock_dati:
if destinazione.exists() and not sovrascrivi:
esistente = self._leggi_json(destinazione) or {}
raise NomeGiaUsato(f"esiste già una calibrazione «{esistente.get('nome', nome)}»")
corpo = {k: v for k, v in dati.items() if k not in ("source", "overlay_png", "saved")}
corpo["nome"] = nome
self._libreria.mkdir(parents=True, exist_ok=True)
# #84: prima la mappa (o la rimozione di una vecchia), poi il JSON che la dichiara
if mappa is not None:
self._salva_mappa(nome, mappa)
else:
self._togli_mappa(nome) # voce planare: una vecchia mappa non vale piu'
self._scrivi_json(destinazione, corpo)
return self._meta(corpo, self._leggi_attive())
def _salva_mappa(self, nome: str, mappa: DistortionMap) -> None:
destinazione = self._file_mappa_nome(nome)
fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp.npz") # .npz: numpy non aggiunge nulla
os.close(fd)
try:
mappa.save(tmp)
os.replace(tmp, destinazione) # atomica come il JSON
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
with self._lock:
self._cache.pop(str(destinazione), None)
def _togli_mappa(self, nome: str) -> None:
destinazione = self._file_mappa_nome(nome)
try:
destinazione.unlink()
except FileNotFoundError:
pass
with self._lock:
self._cache.pop(str(destinazione), None)
def elimina_nome(self, nome: str) -> bool:
"""#91: toglie la voce (JSON e mappa) e la scollega da ogni sorgente. False se non c'era."""
dati = self.leggi_nome(nome)
if dati is None:
return False
canonico = str(dati.get("nome", nome))
with self._lock_dati:
try:
self._file_nome(canonico).unlink()
except FileNotFoundError:
pass
self._togli_mappa(canonico)
attive = self._leggi_attive()
restanti = {s: n for s, n in attive.items() if n.casefold() != canonico.casefold()}
if restanti != attive:
self._scrivi_attive(restanti)
return True
# ---- sorgenti
def attiva(self, sorgente: str, nome: str) -> str:
"""#91: la sorgente usa questa calibrazione; ritorna il nome com'e' in libreria."""
dati = self.leggi_nome(nome)
if dati is None:
raise NomeSconosciuto(nome)
canonico = str(dati["nome"])
with self._lock_dati:
attive = self._leggi_attive()
attive[sorgente] = canonico
self._scrivi_attive(attive)
return canonico
def scollega(self, sorgente: str) -> None:
"""#91: la sorgente non ha piu' una calibrazione attiva; la libreria resta."""
with self._lock_dati:
attive = self._leggi_attive()
if sorgente in attive:
del attive[sorgente]
self._scrivi_attive(attive)
def attiva_di(self, sorgente: str) -> str | None:
"""#91: il nome della calibrazione attiva della sorgente (se esiste ancora)."""
nome = self._leggi_attive().get(sorgente)
dati = self.leggi_nome(nome) if nome else None
return str(dati["nome"]) if dati else None
def leggi(self, sorgente: str) -> dict | None:
"""#91: la calibrazione ATTIVA della sorgente, con "nome" e "source"; None se nessuna."""
nome = self.attiva_di(sorgente)
dati = self.leggi_nome(nome) if nome else None
return {**dati, "source": sorgente} if dati else None
def leggi_mappa(self, sorgente: str) -> MappaPronta | None:
"""La mappa della calibrazione attiva, pronta per `raddrizza`; None se non ne ha una."""
nome = self.attiva_di(sorgente) # #91: la mappa e' della voce attiva, non della sorgente
if nome is None:
return None
percorso = self._file_mappa_nome(nome)
chiave = str(percorso)
try:
mtime = percorso.stat().st_mtime_ns
except OSError:
with self._lock:
self._cache.pop(chiave, None)
return None
with self._lock:
voce = self._cache.get(chiave)
if voce is not None and voce[0] == mtime:
return voce[1]
try:
pronta = _prepara_mappa(DistortionMap.load(percorso))
except (OSError, ValueError, KeyError):
return None # illeggibile = nessuna correzione
with self._lock:
self._cache[chiave] = (mtime, pronta)
return pronta
File diff suppressed because it is too large Load Diff
+817
View File
@@ -0,0 +1,817 @@
"""Servizio camera minimo di stazione (TieMeasureFlow, #72).
Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
GET /camera/sources -> {"sources": [{name, kind, label}]}
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000,
grezzo=false} -> PNG
#84: se la sorgente ha una mappa di distorsione salvata il frame esce
raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è.
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
#91: archivio UNICO di calibrazioni con nome; una sorgente ne ha una attiva:
POST /camera/calibrate multipart: source (obbligatorio solo senza image),
square_mm, image (file, opzionale: senza, grab GREZZO dalla sorgente),
cols/rows (opzionali). NON salva mai (il vecchio campo save e' ignorato).
#84: SENZA cols/rows = calibrazione densa (default): una foto di
scacchiera che riempie il campo, griglia trovata da sola, mappa di
distorsione; mm/px e px/mm sono quelli dell'immagine RADDRIZZATA. CON
cols/rows (angoli interni) = metodo planare di prima. Risposta: i campi
del calcolo + overlay_png (b64), calibrated_at, origine ("camera:<src>" |
"file:<nome>"), calcolo_id (il servizio tiene in memoria gli ultimi 8
calcoli) e metodo ("densa"|"planare"), distorsione (bool) e, per la
densa, residuo_rms_px, residuo_max_px, copertura_pct;
422 scacchiera non trovata o parametri invalidi
GET /camera/calibrazioni[?source=S] -> {calibrazioni: [{nome, origine,
image_width, image_height, px_per_mm, mm_per_px, metodo, square_mm,
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato
POST /camera/calibrazioni/manuale json {source, nome, mm_per_px, sovrascrivi=false}
-> 201 metadati (#95: mm/px impostato a mano, metodo "manuale", nessuna
mappa; non la attiva). 400 mm/px fuori 0.00001..100 o nome non valido,
409 nome gia' usato; la risoluzione e' quella del frame tenuto o di un grab
DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega
da ogni sorgente) | 404
PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla
sorgente e carica la calibrazione solo se la risoluzione (larghezza x
altezza) coincide: 409 altrimenti; 404 nome ignoto; 200 come GET sotto
GET /camera/calibration?source=<src> -> 200 la calibrazione ATTIVA {source,
nome, px_per_mm, mm_per_px, ..., image_width, image_height,
calibrated_at} | 404 "sorgente non calibrata" (lo legge il backend)
DELETE /camera/calibration?source=<src> -> 204 (SCOLLEGA la calibrazione attiva,
la libreria resta)
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
GET /acquisizione -> stessa pagina (#87)
GET /visore/{image_viewer.js|save_image.js|vision_overlay.js} -> i JS del <tmf-viewer> del frontend (#122), altri nomi 404
Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito).
Header: X-Raddrizzato si|no, X-Larghezza/X-Altezza (frame PIENO),
X-Nitidezza (varianza del Laplaciano), X-Saturati-Pct (% pixel >= 250),
X-Media (livello medio 0-255), tutti sul frame pieno in grigio.
GET /camera/parametri?source=<src> -> {source, regolabile, parametri:
{esposizione, esposizione_auto, guadagno, guadagno_auto: dominio}};
dominio = GenicamSource.dominio (valore, tipo, min/max/passo o scelte).
Esposizione in us, guadagno in dB. Sorgente senza capacita': regolabile=false.
PUT /camera/parametri json {source, valori: {nome: valore}} -> come GET.
Scrive prima gli *_auto, poi esposizione/guadagno (assesta=3).
400 nome ignoto o corpo errato, 409 non regolabile, 422 camera rifiuta
POST /camera/parametri/salva json {source} -> {source, salvati, file}
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
della camera nel servizio il file, se c'e', viene ricaricato.
#110: specchiatura per sorgente, in software (cv2.flip) su OGNI frame letto dalla
sorgente, prima di raddrizzatura/calibrazione (anteprima, grab, calibrazione da camera,
copia bloccata); non tocca l'immagine caricata a mano in /camera/calibrate.
GET /camera/orientamento?source=S -> {source, orizzontale, verticale} (default false/false);
400 se manca source
PUT /camera/orientamento json {source, orizzontale: bool, verticale: bool} -> come GET +
"avviso" (stringa o null: la calibrazione attiva ha una mappa fatta con l'orientamento
di prima e va rifatta). 400 se source manca o i due campi non sono bool. Salva subito
su disco; scarta i frame tenuti (anteprima/blocca).
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json, l'orientamento
per sorgente in orientamento.json (#110) (default
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
raggiunge; questo servizio permette di scegliere l'host di ascolto.
Uso (dalla radice del repo):
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>] \
[--config-dir <cartella calibrazioni>]
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
Immagini caricate (#92): POST/DELETE /camera/immagini, in <config-dir>/immagini; sono sorgenti
a pieno titolo (/camera/sources, grab, anteprima, calibrate). POST /camera/anteprima/calibrata
raddrizza l'ultimo frame grezzo dell'anteprima con la calibrazione attiva, senza leggerne uno nuovo.
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
vs-camera, fastapi, uvicorn, opencv, python-multipart (rotta /camera/calibrate),
visionsuite.calibration (vs-core).
AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
camera a chiunque raggiunga la porta: solo su rete isolata.
"""
from __future__ import annotations
import argparse
import json # #110: orientamento.json
import logging
import math # #95: mm/px finito nella calibrazione manuale
import os
import re # #92: nome sicuro per le immagini caricate
import sys
import threading
import time # #92: scadenza della cache delle camere
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
import uuid # #91 id dei calcoli in attesa di nome
from collections import OrderedDict
from contextlib import asynccontextmanager
from pathlib import Path
import cv2
import numpy as np
from fastapi import Body, FastAPI, File, Form, HTTPException, UploadFile
from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuori dall'event loop
from fastapi.responses import HTMLResponse, Response
from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError
from visionsuite.camera.sources import _ESTENSIONI, SourceNotFound, list_sources, open_source # #92: _ESTENSIONI = tipi ammessi per le immagini caricate
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
sys.path.insert(0, str(Path(__file__).resolve().parent))
import calibrazione # noqa: E402
from calibrazione_pagina import PAGINA # noqa: E402
# #122: i tre file JS del frontend che la pagina carica su /visore/<nome> (solo questi nomi: nessun percorso arbitrario dall'URL).
# Cartelle cercate nell'ordine: pacchetto Nuitka (static/ accanto al modulo, copiata da build.ps1) e sorgenti (src/frontend/flask_app/static/js)
_FILE_VISORE = ("image_viewer.js", "save_image.js", "vision_overlay.js")
_QUI = Path(__file__).resolve().parent
_CARTELLE_VISORE = [_QUI / "static" / "js", _QUI.parent / "src" / "frontend" / "flask_app" / "static" / "js"]
_TRIGGER = ("continuo",)
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
# #87: i quattro parametri regolabili, nell'ordine di presentazione
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
# #87: in scrittura prima gli automatismi (un valore manuale con auto acceso viene riscritto)
_ORDINE_SCRITTURA = ("esposizione_auto", "guadagno_auto", "esposizione", "guadagno")
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
_MESSAGGI = {
"Camera is not open": "La camera non è aperta",
"Camera is not grabbing": "La camera non sta acquisendo",
"Grab cancelled (grabbing stopped)": "Acquisizione annullata (acquisizione fermata)",
"Failed to read frame from RTSP stream": "Lettura del fotogramma dallo stream RTSP non riuscita",
}
_PREFISSI = (
("Failed to open OpenCV device", "Apertura del dispositivo OpenCV non riuscita"),
("Failed to open RTSP stream", "Apertura dello stream RTSP non riuscita"),
("Failed to reconnect to RTSP stream", "Riconnessione allo stream RTSP non riuscita"),
("Failed to read frame after", "Lettura del fotogramma non riuscita dopo"),
)
def _traduci(messaggio: str) -> str:
if messaggio in _MESSAGGI:
return _MESSAGGI[messaggio]
for inglese, italiano in _PREFISSI:
if messaggio.startswith(inglese):
return italiano + messaggio[len(inglese):]
return messaggio
def _config_dir_default() -> Path:
"""#83: cartella delle calibrazioni salvate."""
if os.name == "nt":
base = os.environ.get("ProgramData") or "C:/ProgramData"
return Path(base) / "Tielogic" / "TieMeasureFlow" / "acquisitore"
return Path("acquisitore")
def create_app(folder: Path | str | None = None,
config_dir: Path | str | None = None) -> FastAPI:
archivio = calibrazione.Archivio(config_dir if config_dir is not None
else _config_dir_default()) # #83
aperte: dict = {}
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
bloccati: dict = {} # #94: sorgente -> copia del frame grezzo pieno «bloccata» per il calcolo (stesso lock di grezzi)
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
lock_calcoli = threading.Lock()
file_orientamento = archivio.cartella / "orientamento.json" # #110: {sorgente: {orizzontale, verticale}}
def _carica_orientamenti() -> dict:
"""#110: orientamenti salvati; file assente o rotto -> {} (mai eccezione)."""
if not file_orientamento.is_file():
return {}
try:
dati = json.loads(file_orientamento.read_text(encoding="utf-8"))
if not isinstance(dati, dict):
raise ValueError("non e' un oggetto JSON")
return {n: {"orizzontale": v.get("orizzontale") is True, "verticale": v.get("verticale") is True}
for n, v in dati.items() if isinstance(n, str) and isinstance(v, dict)}
except (OSError, ValueError) as e:
_log.warning("orientamento non caricato (%s): %s", file_orientamento, e)
return {}
orientamenti: dict = _carica_orientamenti() # #110: sorgente -> {"orizzontale": bool, "verticale": bool}; protetto da `lock`
def _specchia(nome: str, frame):
"""#110: applica l'orientamento della sorgente al frame letto; nessuno -> lo stesso oggetto, senza copia."""
with lock:
o = orientamenti.get(nome) or {}
orizz, vert = o.get("orizzontale", False), o.get("verticale", False)
if orizz and vert:
return cv2.flip(frame, -1) # entrambi = rotazione di 180 gradi
if orizz:
return cv2.flip(frame, 1)
if vert:
return cv2.flip(frame, 0)
return frame
def _config() -> dict:
if folder is None:
return {}
return {"folder": {"folder": str(folder), "loop": True}}
def _lock_di(nome: str) -> threading.Lock:
with lock:
return lock_sorgenti.setdefault(nome, threading.Lock())
# #92: la ricerca camere di list_sources dura ~10 s: il risultato (gia' filtrato) resta 60 s in cache
cache_camere: dict = {"voci": None, "t": 0.0}
lock_ricerca = threading.Lock() # due richieste insieme non lanciano due ricerche
def _camere(aggiorna: bool = False) -> list:
with lock_ricerca:
fresca = cache_camere["voci"] is not None and time.monotonic() - cache_camere["t"] < _CACHE_CAMERE_S
if fresca and not aggiorna:
return cache_camere["voci"]
voci = list_sources(None, cameras=True, config=_config())
# camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
cache_camere["voci"] = [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
cache_camere["t"] = time.monotonic()
return cache_camere["voci"]
def _prima_ricerca() -> None:
try:
_camere()
except Exception: # noqa: BLE001 - e' solo un riscaldamento: la prima richiesta riprova
_log.exception("prima ricerca camere fallita")
@asynccontextmanager
async def _vita(_app):
# #92: la prima ricerca parte all'avvio, cosi' la prima pagina non aspetta
threading.Thread(target=_prima_ricerca, daemon=True, name="ricerca-camere").start()
yield
with lock: # alla chiusura rilascia le camere aperte
sorgenti = list(aperte.values())
aperte.clear()
for s in sorgenti:
try:
s.close()
except Exception:
pass
app = FastAPI(title="TMF camera service", lifespan=_vita)
@app.get("/camera/sources")
def camera_sources(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
elenco = list(_camere(aggiorna))
# #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
for v in list_sources(cartella_immagini, cameras=False):
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
return {"sources": elenco}
def _chiudi(nome: str) -> None:
"""#92: chiude la sorgente aperta e scorda il frame tenuto (immagine sostituita o tolta)."""
with _lock_di(nome):
with lock:
s = aperte.pop(nome, None)
grezzi.pop(nome, None)
bloccati.pop(nome, None) # #94: tolta l'immagine/chiusa la sorgente, anche la copia bloccata
if s is not None:
try:
s.close()
except Exception: # noqa: BLE001 - la chiusura non deve far fallire la rotta
pass
def _nome_immagine(grezzo: str) -> str:
"""#92: solo il nome del file, coi caratteri sicuri di Archivio._file; "" se non ammesso."""
base = Path((grezzo or "").replace("\\", "/")).name
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("._")
radice, punto, estensione = pulito.rpartition(".")
if not punto or not radice or f".{estensione.lower()}" not in _ESTENSIONI:
return ""
return f"{radice[:100]}.{estensione}"
@app.post("/camera/immagini", status_code=201)
async def immagini_carica(image: UploadFile | None = File(None)):
if image is None:
raise HTTPException(400, "manca il file «image»")
nome = _nome_immagine(image.filename or "")
if not nome:
raise HTTPException(400, "estensione non ammessa: " + ", ".join(_ESTENSIONI))
contenuto = await image.read()
# #92: 422 se non e' un'immagine; la verifica sta PRIMA della scrittura, cosi' non resta nulla su disco
if not contenuto or cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED) is None:
raise HTTPException(422, "il file caricato non è leggibile come immagine")
cartella_immagini.mkdir(parents=True, exist_ok=True)
(cartella_immagini / nome).write_bytes(contenuto) # stesso nome = sostituisce
await run_in_threadpool(_chiudi, nome) # se era aperta, si riapre sul file nuovo
return {"source": nome, "label": f"immagine — {nome}", "kind": "image"}
@app.delete("/camera/immagini", status_code=204)
def immagini_elimina(nome: str = ""):
percorso = cartella_immagini / nome
# #92: solo il nome di un file dentro la cartella, niente percorsi
if not nome or Path(nome).name != nome or not percorso.is_file():
raise HTTPException(404, "immagine non trovata")
_chiudi(nome)
percorso.unlink()
return Response(status_code=204)
def _apri(nome: str):
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
with lock:
sorgente = aperte.get(nome)
if sorgente is None:
sorgente = open_source(nome, cartella_immagini, config=_config()) # #92: un nome di file = immagine caricata
if hasattr(sorgente, "loop"): # #92: l'immagine singola non deve "finire" al secondo grab (anteprima continua)
sorgente.loop = True
# TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda
if hasattr(sorgente, "solo_ultimo_frame"):
try:
sorgente.solo_ultimo_frame()
except Exception as e: # noqa: BLE001 - producer senza il nodo: si apre lo stesso
_log.warning("frame piu' recente non impostabile su %s: %s", nome, e)
_carica_settaggi(nome, sorgente) # #87: predefiniti salvati, se ci sono
with lock:
aperte[nome] = sorgente
return sorgente
def _file_settaggi(nome: str) -> Path:
# #87: stesso schema di nome della calibrazione, in una sottocartella
return archivio.cartella / "settaggi" / archivio._file(nome).name
def _carica_settaggi(nome: str, sorgente) -> None:
percorso = _file_settaggi(nome)
if not percorso.is_file() or not hasattr(sorgente, "carica_settaggi"):
return
try:
sorgente.carica_settaggi(percorso)
except Exception as e: # noqa: BLE001 - un file rotto non blocca l'apertura
_log.warning("settaggi di %s non caricati (%s): %s", nome, percorso, e)
def _con_sorgente(nome: str, fn):
"""#87: fn(sorgente) sotto il lock della sorgente; errori camera -> HTTP."""
try:
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
return fn(_apri(nome))
except SourceNotFound as e:
raise HTTPException(404, _traduci(str(e)))
except TimeoutError as e:
raise HTTPException(504, _traduci(str(e)) or "timeout")
except (CameraGrabError, CameraError) as e:
raise HTTPException(503, _traduci(str(e)))
def _grab(nome: str, timeout: int):
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
return _con_sorgente(nome, lambda s: _specchia(nome, s.grab(timeout))) # #87: corpo in _con_sorgente; #110: specchiato prima di tutto il resto
@app.post("/camera/grab")
def camera_grab(corpo: dict = Body(default=None)):
nome = corpo.get("source") if isinstance(corpo, dict) else None
if not isinstance(nome, str) or not nome.strip():
raise HTTPException(400, "manca «source»")
nome = nome.strip()
trigger = corpo.get("trigger", "continuo")
if trigger not in _TRIGGER:
raise HTTPException(400, "«trigger» supportato: solo continuo")
grezzo = corpo.get("grezzo", False) # #84: true = frame non raddrizzato
if not isinstance(grezzo, bool):
raise HTTPException(400, "«grezzo» deve essere true o false")
timeout = corpo.get("timeout_ms", 5000)
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato
raddrizzato = False
if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache (#91: quella attiva)
mappa = archivio.leggi_mappa(nome)
if mappa is not None:
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is not None: # None = dimensioni diverse da quelle calibrate
frame, raddrizzato = corretto, True
ok, buf = cv2.imencode(".png", frame)
if not ok:
raise HTTPException(500, "codifica PNG fallita")
return Response(buf.tobytes(), media_type="image/png",
headers={"X-Raddrizzato": "si" if raddrizzato else "no"})
# ---- #83: calibrazione a scacchiera per sorgente
def _sorgente(valore) -> str:
if not isinstance(valore, str) or not valore.strip():
raise HTTPException(400, "manca «source»")
return valore.strip()
@app.get("/camera/calibration")
def calibrazione_leggi(source: str = ""):
dati = archivio.leggi(_sorgente(source)) # #91: la calibrazione ATTIVA della sorgente
if dati is None:
raise HTTPException(404, "sorgente non calibrata")
return dati
@app.delete("/camera/calibration", status_code=204)
def calibrazione_elimina(source: str = ""):
archivio.scollega(_sorgente(source)) # #91: scollega soltanto, la libreria resta
return Response(status_code=204)
@app.post("/camera/calibrate")
async def calibrazione_calcola(
source: str | None = Form(None), # #91: serve solo se manca l'immagine
square_mm: float = Form(...),
cols: int | None = Form(None), # #84: opzionali, senza = calibrazione densa
rows: int | None = Form(None),
image: UploadFile | None = File(None),
save: bool = Form(False), # #91: ignorato, il calcolo non salva mai (si salva con nome)
bloccata: bool = Form(False), # #94: true = calcola sulla copia bloccata, senza grab
):
contenuto = await image.read() if image is not None else b""
if contenuto:
frame = cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED)
if frame is None:
raise HTTPException(422, "l'immagine caricata non è leggibile")
origine = "file:" + (Path((image.filename or "").replace("\\", "/")).name or "immagine")
elif bloccata: # #94: copia bloccata, nessun grab e nessun lock camera
nome = _sorgente(source)
with lock:
frame = bloccati.get(nome)
if frame is None:
raise HTTPException(409, "nessuna immagine bloccata su questa sorgente")
origine = nome
else:
nome = _sorgente(source)
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
origine = nome
if (cols is None) != (rows is None):
raise HTTPException(422, "cols e rows vanno indicati insieme (o nessuno dei due)")
mappa = None
try:
if cols is None: # #84: densa, nessuna griglia da dichiarare
risultato, mappa = await run_in_threadpool(
calibrazione.calibra_scacchiera_densa, frame, square_mm)
else: # metodo planare di #83, invariato
risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
except calibrazione.CalibrazioneError as e:
raise HTTPException(422, str(e))
corpo = {**{k: v for k, v in risultato.items() if k != "overlay_png"},
"calibrated_at": calibrazione.ora_iso(), "origine": origine}
calcolo_id = uuid.uuid4().hex
with lock_calcoli: # #91: il calcolo resta in memoria finche' non gli si da' un nome
calcoli[calcolo_id] = (corpo, mappa)
while len(calcoli) > _MAX_CALCOLI:
calcoli.popitem(last=False)
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"],
"bloccata": bool(bloccata and not contenuto)} # #94: solo nella risposta (non nel salvato): true se il frame e' la copia bloccata
# ---- #91: archivio unico di calibrazioni con nome
@app.get("/camera/calibrazioni")
def calibrazioni_elenco(source: str | None = None):
attiva = archivio.attiva_di(_sorgente(source)) if source is not None else None
return {"calibrazioni": archivio.elenco(), "attiva": attiva}
@app.post("/camera/calibrazioni", status_code=201)
def calibrazioni_salva(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
calcolo_id = corpo.get("calcolo_id")
if not isinstance(calcolo_id, str) or not calcolo_id:
raise HTTPException(400, "manca «calcolo_id»")
sovrascrivi = corpo.get("sovrascrivi", False)
if not isinstance(sovrascrivi, bool):
raise HTTPException(400, "«sovrascrivi» deve essere true o false")
try:
nome = calibrazione.pulisci_nome(corpo.get("nome"))
except calibrazione.NomeNonValido as e:
raise HTTPException(400, str(e))
with lock_calcoli:
voce = calcoli.get(calcolo_id)
if voce is None:
raise HTTPException(404, "calcolo non più disponibile: ricalcolare")
dati, mappa = voce
try:
return archivio.salva_nome(nome, dati, mappa, sovrascrivi)
except calibrazione.NomeGiaUsato as e:
raise HTTPException(409, str(e))
@app.post("/camera/calibrazioni/manuale", status_code=201)
def calibrazioni_manuale(corpo: dict = Body(default=None)):
"""#95: calibrazione con mm/px impostato a mano (nessuna scacchiera, nessuna mappa); NON la attiva."""
corpo = corpo if isinstance(corpo, dict) else {}
sorgente = _sorgente(corpo.get("source"))
mm_px = corpo.get("mm_per_px")
if (isinstance(mm_px, bool) or not isinstance(mm_px, (int, float))
or not math.isfinite(mm_px) or not 0.00001 <= mm_px <= 100):
raise HTTPException(400, "mm/px deve essere un numero positivo")
sovrascrivi = corpo.get("sovrascrivi", False)
if not isinstance(sovrascrivi, bool):
raise HTTPException(400, "«sovrascrivi» deve essere true o false")
try:
nome = calibrazione.pulisci_nome(corpo.get("nome"))
except calibrazione.NomeNonValido as e:
raise HTTPException(400, str(e))
with lock:
frame = grezzi.get(sorgente)
if frame is None: # nessun frame tenuto: un grab come /camera/grab (404/503/504)
frame = _grab(sorgente, 5000)
alt, larg = frame.shape[:2] # serve al controllo W×H di PUT /camera/calibrazione-attiva
dati = {"px_per_mm": 1.0 / mm_px, "mm_per_px": float(mm_px), "metodo": "manuale",
"image_width": larg, "image_height": alt, "origine": sorgente,
"calibrated_at": calibrazione.ora_iso(), "rms_px": None, "square_mm": None}
try:
return archivio.salva_nome(nome, dati, None, sovrascrivi)
except calibrazione.NomeGiaUsato as e:
raise HTTPException(409, str(e))
@app.delete("/camera/calibrazioni", status_code=204)
def calibrazioni_elimina(nome: str = ""):
if not archivio.elimina_nome(nome):
raise HTTPException(404, "calibrazione non trovata")
return Response(status_code=204)
@app.put("/camera/calibrazione-attiva")
def calibrazione_attiva(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
sorgente = _sorgente(corpo.get("source"))
richiesto = corpo.get("nome")
dati = archivio.leggi_nome(richiesto) if isinstance(richiesto, str) else None
if dati is None:
raise HTTPException(404, "calibrazione non trovata")
nome = dati["nome"]
frame = _grab(sorgente, 5000) # stesso lock e stessi errori di /camera/grab (404/503/504)
alt, larg = frame.shape[:2]
cw, ch = dati.get("image_width"), dati.get("image_height")
if not isinstance(cw, int) or not isinstance(ch, int):
raise HTTPException(409, f"la calibrazione «{nome}» non ha la risoluzione registrata: "
"non si può caricare")
if (cw, ch) != (larg, alt): # la mappa vale solo per frame della stessa risoluzione
raise HTTPException(409, f"la calibrazione «{nome}» è per immagini {cw}×{ch}, "
f"la sorgente dà {larg}×{alt}: non si può caricare")
archivio.attiva(sorgente, nome)
return archivio.leggi(sorgente)
# ---- #87: acquisizione (anteprima, parametri, settaggi)
def _regolabile(s) -> bool:
# #87: ha il vocabolario delle capacita' e ne supporta almeno una delle quattro
if not (hasattr(s, "supporta") and hasattr(s, "dominio")):
return False
return any(s.supporta(n) for n in _PARAMETRI)
def _parametri(nome: str, s) -> dict:
"""#87: forma di GET /camera/parametri; da chiamare sotto lock."""
parametri: dict = {}
if _regolabile(s):
for n in _PARAMETRI:
if not s.supporta(n):
continue
try:
parametri[n] = s.dominio(n)
except CameraStateError as e: # capacita' dichiarata ma non leggibile: si salta
_log.warning("dominio di %s su %s non letto: %s", n, nome, e)
return {"source": nome, "regolabile": bool(parametri), "parametri": parametri}
def _grigio8(frame):
g = frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if g.dtype == np.uint16: # #87: 16 bit -> 8 bit, per soglia e media coerenti
g = (g >> 8).astype(np.uint8)
elif g.dtype != np.uint8:
g = cv2.convertScaleAbs(g, alpha=255.0 / max(float(g.max()), 1.0))
return g
@app.post("/camera/anteprima")
def camera_anteprima(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
grezzo = corpo.get("grezzo", False)
if not isinstance(grezzo, bool):
raise HTTPException(400, "«grezzo» deve essere true o false")
lato = corpo.get("lato_max", 1280)
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
timeout = corpo.get("timeout_ms", 5000)
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
f = _specchia(nome, s.grab(timeout)) # #110: specchiato PRIMA di tenerlo, cosi' blocca/calibrata lo ricevono gia' specchiato
with lock:
grezzi[nome] = f.copy()
return f
frame = _con_sorgente(nome, leggi)
raddrizzato = False
if not grezzo: # come /camera/grab
mappa = archivio.leggi_mappa(nome)
if mappa is not None:
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is not None:
frame, raddrizzato = corretto, True
alt, larg = frame.shape[:2]
grigio = _grigio8(frame) # misure sul frame PIENO, non sul ridotto
nitidezza = float(cv2.Laplacian(grigio, cv2.CV_64F).var())
saturati = float((grigio >= 250).mean() * 100.0)
media = float(grigio.mean())
if frame.dtype != np.uint8: # il JPEG vuole 8 bit
frame = grigio
scala = lato / max(alt, larg)
if scala < 1.0: # mai ingrandita
frame = cv2.resize(frame, (max(1, round(larg * scala)), max(1, round(alt * scala))),
interpolation=cv2.INTER_AREA)
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
if not ok:
raise HTTPException(500, "codifica JPEG fallita")
return Response(buf.tobytes(), media_type="image/jpeg", headers={
"X-Raddrizzato": "si" if raddrizzato else "no",
"X-Larghezza": str(larg), "X-Altezza": str(alt),
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
"X-Media": f"{media:.1f}"})
@app.post("/camera/blocca")
def camera_blocca(corpo: dict = Body(default=None)):
"""#94: copia l'ultimo frame grezzo della sorgente; /camera/calibrate con bloccata=true calcola su quella."""
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
with lock:
frame = grezzi.get(nome)
if frame is None:
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
bloccati[nome] = frame.copy()
alt, larg = frame.shape[:2]
return {"source": nome, "larghezza": larg, "altezza": alt}
@app.delete("/camera/blocca", status_code=204)
def camera_sblocca(source: str = ""):
with lock: # #94: idempotente, 204 anche se non c'era nulla
bloccati.pop(source.strip(), None)
return Response(status_code=204)
@app.post("/camera/anteprima/calibrata")
def camera_anteprima_calibrata(corpo: dict = Body(default=None)):
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo. #95: senza mappa esce grezzo (X-Raddrizzato: no)."""
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
lato = corpo.get("lato_max", 1280)
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
with lock:
frame = grezzi.get(nome)
if frame is None:
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
attiva = archivio.attiva_di(nome)
if attiva is None:
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
mappa = archivio.leggi_mappa(nome)
if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione
dati = archivio.leggi(nome) or {}
cw, ch = dati.get("image_width"), dati.get("image_height")
alt, larg = frame.shape[:2]
if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt):
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
corretto, raddrizzato = frame, "no"
else:
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is None: # risoluzione diversa da quella calibrata
ch, cw = mappa.shape[:2]
alt, larg = frame.shape[:2]
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
raddrizzato = "si"
alt, larg = corretto.shape[:2]
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
corretto = _grigio8(corretto)
scala = lato / max(alt, larg)
if scala < 1.0: # mai ingrandita
corretto = cv2.resize(corretto, (max(1, round(larg * scala)), max(1, round(alt * scala))),
interpolation=cv2.INTER_AREA)
ok, buf = cv2.imencode(".jpg", corretto, [cv2.IMWRITE_JPEG_QUALITY, 85])
if not ok:
raise HTTPException(500, "codifica JPEG fallita")
return Response(buf.tobytes(), media_type="image/jpeg", headers={
"X-Raddrizzato": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
@app.get("/camera/parametri")
def parametri_leggi(source: str = ""):
nome = _sorgente(source)
return _con_sorgente(nome, lambda s: _parametri(nome, s))
@app.put("/camera/parametri")
def parametri_scrivi(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
valori = corpo.get("valori")
if not isinstance(valori, dict) or not valori:
raise HTTPException(400, "manca «valori»")
for n, v in valori.items():
if n not in _PARAMETRI:
raise HTTPException(400, f"parametro non riconosciuto: «{n}»")
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
raise HTTPException(400, f"valore non valido per «{n}»")
def scrivi(s):
if not _regolabile(s):
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
for n in _ORDINE_SCRITTURA: # prima gli *_auto
if n in valori:
try:
s.scrivi(n, valori[n], assesta=3)
except CameraStateError as e:
raise HTTPException(422, _traduci(str(e)))
return _parametri(nome, s) # valori riletti dalla camera
return _con_sorgente(nome, scrivi)
@app.post("/camera/parametri/salva")
def parametri_salva(corpo: dict = Body(default=None)):
nome = _sorgente(corpo.get("source") if isinstance(corpo, dict) else None)
def salva(s):
if not _regolabile(s) or not hasattr(s, "salva_settaggi"):
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
percorso = _file_settaggi(nome)
percorso.parent.mkdir(parents=True, exist_ok=True)
s.salva_settaggi(percorso)
return {"source": nome, "salvati": True, "file": percorso.name}
return _con_sorgente(nome, salva)
# ---- #110: specchiatura orizzontale/verticale per sorgente
def _orientamento_di(nome: str) -> dict:
with lock:
o = orientamenti.get(nome) or {}
return {"source": nome, "orizzontale": bool(o.get("orizzontale")), "verticale": bool(o.get("verticale"))}
@app.get("/camera/orientamento")
def orientamento_leggi(source: str = ""):
return _orientamento_di(_sorgente(source))
@app.put("/camera/orientamento")
def orientamento_scrivi(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
orizz, vert = corpo.get("orizzontale"), corpo.get("verticale")
if not isinstance(orizz, bool) or not isinstance(vert, bool):
raise HTTPException(400, "«orizzontale» e «verticale» devono essere true o false")
with lock:
prima = orientamenti.get(nome) or {}
prima_voce = orientamenti.get(nome) # #110: voce di prima (None = non c'era), per il ripristino se il salvataggio fallisce
cambiato = (bool(prima.get("orizzontale")), bool(prima.get("verticale"))) != (orizz, vert)
if orizz or vert:
orientamenti[nome] = {"orizzontale": orizz, "verticale": vert}
else:
orientamenti.pop(nome, None) # tutti false: la sorgente esce dal file
# scrittura atomica di tutto il dizionario (tmp + replace)
try:
file_orientamento.parent.mkdir(parents=True, exist_ok=True)
tmp = file_orientamento.with_name(file_orientamento.name + ".tmp")
tmp.write_text(json.dumps(orientamenti, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, file_orientamento)
except OSError as e:
_log.warning("orientamento non salvato (%s): %s", file_orientamento, e)
if prima_voce is None: # #110: salvataggio fallito -> in memoria resta l'orientamento di prima
orientamenti.pop(nome, None)
else:
orientamenti[nome] = prima_voce
raise HTTPException(500, "salvataggio dell'orientamento non riuscito")
# i frame tenuti hanno l'orientamento di prima
grezzi.pop(nome, None)
bloccati.pop(nome, None)
avviso = None
if cambiato and archivio.leggi_mappa(nome) is not None:
avviso = (f"la calibrazione attiva «{archivio.attiva_di(nome)}» corregge la distorsione "
"con l'orientamento di prima: va rifatta")
return {**_orientamento_di(nome), "avviso": avviso}
@app.get("/calibrazione", response_class=HTMLResponse)
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
def calibrazione_pagina():
return HTMLResponse(PAGINA)
@app.get("/visore/{nome}")
def visore_file(nome: str):
"""#122: i file JS del <tmf-viewer> letti dal frontend Flask; la pagina resta offline."""
if nome not in _FILE_VISORE: # lista chiusa: niente percorsi dall'URL
raise HTTPException(404, "file non trovato")
for cartella in _CARTELLE_VISORE:
f = cartella / nome
if f.is_file():
return Response(f.read_bytes(), media_type="application/javascript", headers={"Cache-Control": "no-cache"})
raise HTTPException(404, "file non trovato")
return app
def main() -> None:
import uvicorn
p = argparse.ArgumentParser(description="Servizio camera minimo di stazione (#72)")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8765)
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder")
p.add_argument("--config-dir", default=None,
help="cartella delle calibrazioni salvate (default: ProgramData su Windows)") # #83
a = p.parse_args()
uvicorn.run(create_app(a.folder, a.config_dir), host=a.host, port=a.port)
if __name__ == "__main__":
main()
+400
View File
@@ -0,0 +1,400 @@
"""Seed the recipes used to accept points 2, 3, 5, 8, 9 and 11 on a real screen.
The tests prove the rules hold; this puts them in front of an operator. Two
recipes, because they need opposite settings:
COLLAUDO-A typing allowed, lot compulsory, two measurement tasks with
documental tasks before, between and after them, interval of two
minutes. This is the one to drive: the measurement loop, the
bidirectional countdown, the out-of-tolerance gate, the cycle that
closes only on the last measurement task.
COLLAUDO-B caliper only, lot and serial compulsory. This one is to look at:
the keypad is not drawn, and Avvia does not start until both
traceability fields are filled in.
Run it inside the server container, where src.backend and the database settings
are already in place:
docker compose cp scripts/seed_collaudo.py server:/tmp/seed_collaudo.py
docker compose exec server uv run python /tmp/seed_collaudo.py --station ST-DEFAULT
Re-running finds the recipes already there and stops. --replace deletes them
first, along with anything measured against them - which is what you want between
one acceptance session and the next, and never what you want by accident: it
prints how many Measurement/ProductionRun/ProductionEvent rows that is and
refuses to touch them without --si.
Refuses to run against a real deployment (APP_ENV outside dev/test, same rule
as src.backend.config) unless --conferma-produzione is also given: this script
deletes data, and a shop floor is not where to find that out by mistake.
If --supervisor-password is not given and the capoturno account does not exist
yet, a random one is generated and printed once - never committed, never a
default anyone could look up.
"""
import argparse
import asyncio
import secrets
import sys
from sqlalchemy import delete, func, select
sys.path.insert(0, "/app")
from src.backend.config import settings # noqa: E402
from src.backend.database import async_session_factory # noqa: E402
from src.backend.models.orm.measurement import Measurement # noqa: E402
from src.backend.models.orm.production import ( # noqa: E402
ProductionEvent, ProductionRun,
)
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
from src.backend.models.orm.station import ( # noqa: E402
Station, StationRecipeAssignment,
)
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
from src.backend.models.orm.user import User # noqa: E402
from src.backend.services import auth_service # noqa: E402
CODES = ("COLLAUDO-A", "COLLAUDO-B")
# Limits chosen so every outcome is one deliberate keystroke away:
# 10.00 -> conforme 10.30 -> attenzione (dentro tolleranza)
# 12.00 -> fuori tolleranza, serve il capoturno
QUOTES_A1 = [
dict(marker_number=1, description="Diametro esterno",
nominal=10.0, ltl=9.5, lwl=9.8, uwl=10.2, utl=10.5, unit="mm"),
dict(marker_number=2, description="Spessore parete",
nominal=25.0, ltl=24.5, lwl=24.8, uwl=25.2, utl=25.5, unit="mm"),
]
QUOTES_A2 = [
dict(marker_number=3, description="Lunghezza totale",
nominal=100.0, ltl=99.0, lwl=99.5, uwl=100.5, utl=101.0, unit="mm"),
]
QUOTES_B = [
dict(marker_number=1, description="Diametro con calibro",
nominal=10.0, ltl=9.5, lwl=9.8, uwl=10.2, utl=10.5, unit="mm"),
]
# A description with a blank line and a bold phrase: point 11 is visible or it is
# not, and this is where you look.
NOTA_INIZIALE = (
"Prendere il pezzo dalla cassetta **a sinistra** della postazione.\n"
"Pulire la superficie prima di misurare.\n\n"
"**Attenzione**: non misurare pezzi ancora caldi."
)
NOTA_INTERMEDIA = (
"Girare il pezzo di **90 gradi** prima della seconda misura.\n"
"Se il pezzo non appoggia in piano, ripetere il posizionamento."
)
NOTA_FINALE = (
"Riporre il pezzo nella cassetta **a destra**.\n"
"Segnalare al capoturno qualsiasi quota fuori tolleranza."
)
async def _find_recipes(session, codes):
result = await session.execute(select(Recipe).where(Recipe.code.in_(codes)))
return list(result.scalars().all())
async def _count_replace_impact(session, recipes) -> dict:
"""How many Measurement/ProductionRun/ProductionEvent --replace would delete.
Read-only mirror of what _delete_recipes actually removes, so the count
printed before the confirmation matches the count deleted after it.
"""
version_ids = []
run_ids = []
for recipe in recipes:
version_ids += (await session.execute(
select(RecipeVersion.id).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().all()
run_ids += (await session.execute(
select(ProductionRun.id).where(ProductionRun.recipe_id == recipe.id)
)).scalars().all()
measurements = 0
if version_ids:
measurements = (await session.execute(
select(func.count()).select_from(Measurement)
.where(Measurement.version_id.in_(version_ids))
)).scalar_one()
events = 0
if run_ids:
events = (await session.execute(
select(func.count()).select_from(ProductionEvent)
.where(ProductionEvent.run_id.in_(run_ids))
)).scalar_one()
return {
"measurements": measurements,
"production_runs": len(run_ids),
"production_events": events,
}
async def _delete_recipes(session, recipes):
"""Remove the collaudo recipes and everything measured against them."""
for recipe in recipes:
versions = (await session.execute(
select(RecipeVersion.id).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().all()
runs = (await session.execute(
select(ProductionRun.id).where(ProductionRun.recipe_id == recipe.id)
)).scalars().all()
if versions:
await session.execute(
delete(Measurement).where(Measurement.version_id.in_(versions))
)
if runs:
await session.execute(
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
)
await session.execute(
delete(ProductionRun).where(ProductionRun.id.in_(runs))
)
await session.execute(
delete(StationRecipeAssignment).where(
StationRecipeAssignment.recipe_id == recipe.id
)
)
# Versions, tasks and subtasks go with the recipe through the ORM cascade.
await session.delete(recipe)
await session.flush()
async def _add_task(session, version_id, order_index, title, task_type,
directive=None, description=None, quotes=()):
task = RecipeTask(
version_id=version_id,
order_index=order_index,
title=title,
task_type=task_type,
directive=directive,
description=description,
)
session.add(task)
await session.flush()
for quote in quotes:
session.add(RecipeSubtask(task_id=task.id, **quote))
await session.flush()
return task
async def _create_recipe(session, user_id, *, code, name, description,
interval, requires_lot, requires_serial,
allow_manual_input):
recipe = Recipe(
code=code,
name=name,
description=description,
created_by=user_id,
active=True,
measurement_interval_minutes=interval,
requires_lot=requires_lot,
requires_serial=requires_serial,
allow_manual_input=allow_manual_input,
)
session.add(recipe)
await session.flush()
version = RecipeVersion(
recipe_id=recipe.id,
version_number=1,
is_current=True,
created_by=user_id,
change_notes="Ricetta di collaudo",
)
session.add(version)
await session.flush()
return recipe, version
async def _assign(session, station, recipe, user_id):
session.add(StationRecipeAssignment(
station_id=station.id, recipe_id=recipe.id, assigned_by=user_id,
))
await session.flush()
async def _ensure_supervisor(session, username, password):
"""A capoturno with the role, not an admin standing in for one.
An admin is accepted by the authorisation check, but the acceptance session
should exercise the role the shop floor will actually use.
`password` may be None: it is only needed when the account does not exist
yet, in which case a random one is generated here rather than falling
back to a committed default. Returns (user, created, password_used) so
the caller can print the generated one - the only place it appears.
"""
existing = (await session.execute(
select(User).where(User.username == username)
)).scalar_one_or_none()
if existing is not None:
return existing, False, password
password_used = password or secrets.token_urlsafe(12)
user = await auth_service.create_user(
session,
username=username,
password=password_used,
display_name="Capo Turno (collaudo)",
roles=["Supervisor", "MeasurementTec"],
)
await session.flush()
return user, True, password_used
async def seed(
station_code: str, replace: bool, supervisor_password: str | None, si: bool,
) -> int:
async with async_session_factory() as session:
owner = (await session.execute(
select(User).where(User.is_admin == True) # noqa: E712
)).scalars().first()
if owner is None:
print("Nessun utente amministratore: impossibile intestare le ricette.")
return 2
station = (await session.execute(
select(Station).where(Station.code == station_code)
)).scalar_one_or_none()
if station is None:
print(f"Stazione '{station_code}' inesistente.")
return 2
existing = await _find_recipes(session, CODES)
if existing and not replace:
found = ", ".join(r.code for r in existing)
print(f"Ricette di collaudo gia' presenti ({found}).")
print("Usa --replace per rifarle da zero (cancella anche le misure).")
return 1
if existing:
impact = await _count_replace_impact(session, existing)
print(f"--replace cancellera' ({', '.join(r.code for r in existing)}):")
print(f" {impact['measurements']} misure")
print(f" {impact['production_runs']} produzioni")
print(f" {impact['production_events']} eventi di produzione")
if not si:
print("Nessuna cancellazione eseguita. Aggiungi --si per confermare.")
return 1
await _delete_recipes(session, existing)
print(f"Rimosse: {', '.join(r.code for r in existing)}")
# ---- COLLAUDO-A: the one to drive ----
recipe_a, version_a = await _create_recipe(
session, owner.id,
code="COLLAUDO-A",
name="Collaudo — ciclo di misura",
description=(
"Due task di misura con task documentali intorno. "
"Lotto obbligatorio, inserimento manuale consentito, "
"intervallo di 2 minuti."
),
interval=2,
requires_lot=True,
requires_serial=False,
allow_manual_input=True,
)
await _add_task(
session, version_a.id, 0, "Preparazione del pezzo", "note",
directive="Leggere prima di iniziare",
description=NOTA_INIZIALE,
)
await _add_task(
session, version_a.id, 1, "Misura 1 — diametro e spessore", "measure",
directive="Misurare le due quote in sequenza",
description="Quota **1** e quota **2**.\nUsare il calibro o il tastierino.",
quotes=QUOTES_A1,
)
await _add_task(
session, version_a.id, 2, "Riposizionamento", "note",
directive="Fra la prima e la seconda misura",
description=NOTA_INTERMEDIA,
)
await _add_task(
session, version_a.id, 3, "Misura 2 — lunghezza", "measure",
directive="Ultimo task di misura: chiude il ciclo",
description="Alla conferma di questa quota **riparte l'intervallo**.",
quotes=QUOTES_A2,
)
await _add_task(
session, version_a.id, 4, "Chiusura", "note",
directive="Dopo le misure",
description=NOTA_FINALE,
)
await _assign(session, station, recipe_a, owner.id)
# ---- COLLAUDO-B: the one to look at ----
recipe_b, version_b = await _create_recipe(
session, owner.id,
code="COLLAUDO-B",
name="Collaudo — solo calibro",
description=(
"Lotto e seriale obbligatori, inserimento manuale vietato: "
"il tastierino non compare e Avvia non parte finche' mancano i dati."
),
interval=5,
requires_lot=True,
requires_serial=True,
allow_manual_input=False,
)
await _add_task(
session, version_b.id, 0, "Misura con calibro", "measure",
directive="Il valore deve arrivare dal calibro",
description="Nessun tastierino: la ricetta **non ammette valori digitati**.",
quotes=QUOTES_B,
)
await _assign(session, station, recipe_b, owner.id)
supervisor, created, password_used = await _ensure_supervisor(
session, "capoturno", supervisor_password,
)
await session.commit()
print(f"Ricette create e assegnate alla stazione {station_code}:")
print(" COLLAUDO-A ciclo di misura, lotto obbligatorio, intervallo 2 min")
print(" COLLAUDO-B solo calibro, lotto e seriale obbligatori")
if created:
print(f"Capoturno creato: {supervisor.username} / {password_used}")
print(" cambiare la password prima di qualunque uso reale.")
else:
print(f"Capoturno gia' presente: {supervisor.username} (password invariata)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--station", default="ST-DEFAULT",
help="codice della stazione a cui assegnare le ricette")
parser.add_argument("--replace", action="store_true",
help="cancella le ricette di collaudo esistenti e le misure fatte su di esse")
parser.add_argument("--si", action="store_true",
help="conferma la cancellazione di --replace (senza, si limita a mostrarla)")
parser.add_argument("--supervisor-password", default=None,
help="password del capoturno, se va creato; "
"se omessa ne viene generata una casuale e stampata una volta")
parser.add_argument("--conferma-produzione", action="store_true",
help="richiesto per lanciare lo script con APP_ENV di produzione")
args = parser.parse_args()
if settings.app_env not in ("dev", "test") and not args.conferma_produzione:
print(
f"APP_ENV={settings.app_env!r} indica una produzione: "
"questo script scrive ricette e utenti di collaudo. "
"Rilancia con --conferma-produzione se e' davvero quello che vuoi."
)
return 2
return asyncio.run(
seed(args.station, args.replace, args.supervisor_password, args.si)
)
if __name__ == "__main__":
raise SystemExit(main())
+458
View File
@@ -0,0 +1,458 @@
"""Seed per il PC della fiera: utenti, stazione, tre ricette e quattro settimane di misure.
Cosa fa
Popola un database TieMeasureFlow (SQLite del pacchetto Windows) cosi' che al primo
avvio NON compaia la procedura di setup e la demo abbia gia' qualcosa da mostrare:
- utenti adriano (admin), maker, capoturno, operatore (password nella costante
UTENTI qui sotto, decise da Adriano per la fiera);
- la stazione ST-DEFAULT (quella del pacchetto, vedi packaging/windows/avvio.py)
con camera_url http://127.0.0.1:8765 e camera_source camera:601020 (Itala);
- FIERA-FLANGIA «Flangia Ø80» (5 quote) e FIERA-ALBERO «Albero rettificato»
(4 quote), con circa 4 settimane di storico fino a ieri (lun-ven 8-17, una
produzione chiusa per giorno, lotti e seriali coerenti, misure con Cpk diversi
per quota, una deriva lenta nell'ultima settimana, una quota con 1-3% fuori
tolleranza);
- FIERA-DXF «Profilo DXF (visione)»: un task dxf_compare senza ricetta di visione
(si crea in fiera dal pannello «Crea ricetta da DXF»).
«Setup fatto» per il backend vuol dire: esiste almeno un utente admin
(api/routers/setup.py::_admin_exists), quindi basta creare adriano.
Come si usa (da daitarn, TMF fermo sul PC, su una COPIA del file SQLite)
uv run python scripts/seed_fiera.py --db C:/percorso/tmf.db [--replace] [--dry-run]
[--fine AAAA-MM-GG]
Il file deve avere gia' lo schema (alembic upgrade head: lo fa l'exe al primo
avvio). --replace toglie solo i dati FIERA-* (ricette, misure, produzioni) e li
rifa'; senza, un secondo giro non duplica le ricette. Gli utenti e la stazione
sono idempotenti: se esistono si aggiornano password, ruoli e camera.
--dry-run fa tutto e annulla (stampa i numeri). Il seme casuale e' fisso: stesso
comando e stessa data di fine, stessi dati.
Da cosa dipende
src.backend (modelli ORM, auth_service, measurement_service), SQLAlchemy +
aiosqlite, tzdata su Windows (gia' in pyproject). Non usa MySQL.
"""
import argparse
import asyncio
import random
import sys
from datetime import date, datetime, time, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from sqlalchemy import delete, event, select
from sqlalchemy.ext.asyncio import (
AsyncSession, async_sessionmaker, create_async_engine,
)
# La radice del repo nel path, cosi' "src.backend" si importa lanciando lo script da ovunque
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.backend.database import async_session_factory # noqa: E402
from src.backend.models.orm.measurement import Measurement # noqa: E402
from src.backend.models.orm.production import ( # noqa: E402
ProductionEvent, ProductionRun,
)
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
from src.backend.models.orm.setting import RecipeVersionAudit # noqa: E402
from src.backend.models.orm.station import ( # noqa: E402
Station, StationRecipeAssignment,
)
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
from src.backend.models.orm.task_result import TaskResult # noqa: E402
from src.backend.models.orm.user import User # noqa: E402
from src.backend.services import auth_service # noqa: E402
from src.backend.services.measurement_service import calculate_pass_fail # noqa: E402
CODES = ("FIERA-FLANGIA", "FIERA-ALBERO", "FIERA-DXF")
STATION_CODE = "ST-DEFAULT" # STATION_CODE del pacchetto Windows (avvio.py PREDEFINITI)
CAMERA_URL = "http://127.0.0.1:8765"
CAMERA_SOURCE = "camera:601020" # Itala sul PC fiera
SEED = 20261026 # seme fisso: dati ripetibili
FUSO = ZoneInfo("Europe/Rome") # le misure sono salvate in UTC naive, l'orario di turno e' locale
GIORNI_STORICO = 28 # circa 4 settimane
# (username, password, nome, ruoli, admin). Password "123" per tutti, chiesta da Adriano per la fiera (#85); il login non controlla la lunghezza.
UTENTI = [
("adriano", "123", "Adriano Dal Pastro",
["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True),
("maker", "123", "Marco Ferrari (Maker)", ["Maker"], False),
("capoturno", "123", "Luca Bianchi (Capoturno)",
["Supervisor", "MeasurementTec"], False),
("operatore", "123", "Giulia Rossi (Operatore)", ["MeasurementTec"], False),
]
def _quota(marker, descrizione, nominale, tol, unita="mm", dec=3):
"""Quota simmetrica: tolleranza = nominale +/- tol, warning al 70% della tolleranza."""
return dict(
marker_number=marker, description=descrizione, nominal=nominale,
ltl=round(nominale - tol, dec), lwl=round(nominale - 0.7 * tol, dec),
uwl=round(nominale + 0.7 * tol, dec), utl=round(nominale + tol, dec),
unit=unita,
)
# Profilo statistico di una quota: scostamento medio (in frazioni di tol), sigma (in
# frazioni di tol) e deriva (frazione di tol aggiunta linearmente nell'ultima settimana).
# Cpk ~ (1 - |scostamento|) / (3 * sigma): sigma 0.2 -> 1,67; 0.33 -> 1,0.
FLANGIA = [
# (task, quota, scostamento, sigma, deriva)
(0, _quota(1, "Diametro esterno Ø80", 80.0, 0.05), 0.0, 0.20, 0.0), # Cpk ~1,67 capace
(0, _quota(2, "Diametro foro centrale Ø25", 25.0, 0.02), 0.0, 0.33, 0.0), # Cpk ~1,0 al limite
(1, _quota(3, "Spessore flangia", 12.0, 0.10), 0.0, 0.22, 0.55), # deriva ultima settimana
(1, _quota(4, "Interasse fori Ø60", 60.0, 0.10), 0.10, 0.25, 0.0), # Cpk ~1,2
(1, _quota(5, "Profondita' lamatura", 8.0, 0.15), 0.50, 0.244, 0.0), # ~2% fuori tolleranza
]
ALBERO = [
(0, _quota(1, "Diametro cuscinetto Ø20 h6", 20.0, 0.010), 0.0, 0.20, 0.0), # Cpk ~1,67
(0, _quota(2, "Diametro sede Ø25 j5", 25.0, 0.008), 0.0, 0.25, 0.0), # Cpk ~1,33
(1, _quota(3, "Lunghezza totale", 150.0, 0.10), 0.0, 0.33, 0.0), # Cpk ~1,0
(1, _quota(4, "Rugosita' Ra", 0.40, 0.20, unita="µm"), 0.20, 0.25, 0.0), # Cpk ~1,07
]
def _ora_utc(giorno: date, minuti_da_mezzanotte: int) -> datetime:
"""Ora locale di officina -> datetime UTC naive, come le salva il backend."""
locale = datetime.combine(giorno, time(0, 0), tzinfo=FUSO) + timedelta(minutes=minuti_da_mezzanotte)
return locale.astimezone(timezone.utc).replace(tzinfo=None)
def _giorni_lavorativi(fine: date) -> list[date]:
"""Lun-ven degli ultimi GIORNI_STORICO giorni, fino a `fine` compreso."""
giorni = [fine - timedelta(days=i) for i in range(GIORNI_STORICO)]
return sorted(g for g in giorni if g.weekday() < 5)
def _slot_giornalieri(rng: random.Random, n: int) -> list[int]:
"""n orari (minuti dalla mezzanotte) in 8-12 e 13-17, ogni ~30 min con un po' di scarto."""
mezz_ore = [8 * 60 + 30 * i for i in range(8)] + [13 * 60 + 30 * i for i in range(8)]
scelti = sorted(rng.sample(mezz_ore, n))
return [m + rng.randint(0, 9) for m in scelti]
async def _upsert_utenti(session: AsyncSession) -> dict[str, User]:
"""Crea gli utenti o, se esistono, ne aggiorna password, nome, ruoli e flag admin."""
utenti = {}
for username, password, nome, ruoli, admin in UTENTI:
esistente = (await session.execute(
select(User).where(User.username == username)
)).scalar_one_or_none()
if esistente is None:
utenti[username] = await auth_service.create_user(
session, username=username, password=password, display_name=nome,
roles=ruoli, is_admin=admin,
)
else:
esistente.password_hash = auth_service.hash_password(password)
esistente.display_name = nome
esistente.roles = ruoli
esistente.is_admin = admin
esistente.active = True
utenti[username] = esistente
await session.flush()
return utenti
async def _upsert_stazione(session: AsyncSession, owner: User) -> Station:
"""La stazione del pacchetto, con la camera Itala; se c'e' gia' ne aggiorna la camera."""
stazione = (await session.execute(
select(Station).where(Station.code == STATION_CODE)
)).scalar_one_or_none()
if stazione is None:
stazione = Station(
code=STATION_CODE, name="Postazione fiera", location="Stand",
created_by=owner.id,
)
session.add(stazione)
stazione.camera_url = CAMERA_URL
stazione.camera_source = CAMERA_SOURCE
await session.flush()
return stazione
async def _conta_e_cancella(session: AsyncSession, ricette: list[Recipe]) -> dict:
"""Toglie le ricette FIERA-* e tutto cio' che e' misurato contro di esse. Ritorna i conteggi."""
conti = {"misure": 0, "produzioni": 0, "eventi": 0}
for ricetta in ricette:
versioni = (await session.execute(
select(RecipeVersion.id).where(RecipeVersion.recipe_id == ricetta.id)
)).scalars().all()
runs = (await session.execute(
select(ProductionRun.id).where(ProductionRun.recipe_id == ricetta.id)
)).scalars().all()
if versioni:
conti["misure"] += (await session.execute(
delete(Measurement).where(Measurement.version_id.in_(versioni))
)).rowcount
await session.execute( # esiti dei task di queste versioni (FK sulle versioni)
delete(TaskResult).where(TaskResult.version_id.in_(versioni))
)
if runs:
conti["eventi"] += (await session.execute(
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
)).rowcount
conti["produzioni"] += (await session.execute(
delete(ProductionRun).where(ProductionRun.id.in_(runs))
)).rowcount
await session.execute(
delete(StationRecipeAssignment).where(StationRecipeAssignment.recipe_id == ricetta.id)
)
await session.execute(
delete(RecipeVersionAudit).where(RecipeVersionAudit.recipe_id == ricetta.id)
)
# Versioni, task e quote se ne vanno con la cascade ORM della ricetta.
await session.delete(ricetta)
await session.flush()
return conti
async def _nuova_ricetta(session, owner, stazione, *, code, name, description,
interval, requires_serial, note_versione):
"""Ricetta + versione 1 corrente + assegnazione alla stazione."""
ricetta = Recipe(
code=code, name=name, description=description, created_by=owner.id, active=True,
measurement_interval_minutes=interval, requires_lot=True,
requires_serial=requires_serial, allow_manual_input=True,
)
session.add(ricetta)
await session.flush()
versione = RecipeVersion(
recipe_id=ricetta.id, version_number=1, is_current=True,
created_by=owner.id, change_notes=note_versione,
)
session.add(versione)
session.add(StationRecipeAssignment(
station_id=stazione.id, recipe_id=ricetta.id, assigned_by=owner.id,
))
await session.flush()
return ricetta, versione
async def _task(session, versione, ordine, titolo, tipo, direttiva=None,
descrizione=None, quote=()):
task = RecipeTask(
version_id=versione.id, order_index=ordine, title=titolo, task_type=tipo,
directive=direttiva, description=descrizione,
)
session.add(task)
await session.flush()
sottotask = []
for q in quote:
st = RecipeSubtask(task_id=task.id, **q)
session.add(st)
sottotask.append(st)
await session.flush()
return task, sottotask
async def _ricetta_meccanica(session, owner, stazione, *, code, name, description,
interval, requires_serial, profilo, titoli_task):
"""Ricetta con una nota iniziale e un task di misura per ogni gruppo di quote del profilo."""
ricetta, versione = await _nuova_ricetta(
session, owner, stazione, code=code, name=name, description=description,
interval=interval, requires_serial=requires_serial,
note_versione="Ricetta dimostrativa per la fiera",
)
await _task(session, versione, 0, "Preparazione del pezzo", "note",
direttiva="Prima di misurare",
descrizione="Pulire il pezzo e controllare che sia a **temperatura ambiente**.")
sottotask_per_quota = {}
for indice, titolo in enumerate(titoli_task):
quote = [q for (t, q, *_r) in profilo if t == indice]
_t, sottotask = await _task(
session, versione, indice + 1, titolo, "measure",
direttiva="Misurare le quote in sequenza", quote=quote,
)
for st in sottotask:
sottotask_per_quota[st.marker_number] = st
return ricetta, versione, sottotask_per_quota
def _valore(rng, quota, scost, sigma, deriva, frazione_tempo):
"""Un valore: normale attorno a nominale + scostamento (+ deriva nell'ultima settimana)."""
tol = quota["utl"] - quota["nominal"]
media = quota["nominal"] + tol * (scost + deriva * max(0.0, (frazione_tempo - 0.75) / 0.25))
return round(rng.gauss(media, tol * sigma), 6)
async def _storico(session, rng, fine, owner_ricetta, stazione, utenti, ricetta, versione,
sottotask, profilo, pezzi_giorno, prefisso_lotto, prefisso_seriale):
"""Una produzione chiusa per giorno lavorativo, con i suoi pezzi, eventi e misure."""
giorni = _giorni_lavorativi(fine)
operatore, capoturno = utenti["operatore"], utenti["capoturno"]
task_ids = {} # marker -> id del task di misura
for marker, st in sottotask.items():
task_ids[marker] = st.task_id
ordine_task = sorted(set(task_ids.values()))
ultimo_task = ordine_task[-1]
contatore = 0
totale = 0
for indice_giorno, giorno in enumerate(giorni):
frazione = indice_giorno / max(1, len(giorni) - 1) # 0 = primo giorno, 1 = ieri
n = rng.randint(*pezzi_giorno)
slot = _slot_giornalieri(rng, n)
lotto = f"{prefisso_lotto}{giorno:%y%m%d}"
inizio, fine_run = _ora_utc(giorno, slot[0] - 5), _ora_utc(giorno, slot[-1] + 10)
run = ProductionRun(
station_id=stazione.id, recipe_id=ricetta.id, version_id=versione.id,
operator_id=operatore.id, lot_number=lotto, status="closed",
measurement_interval_minutes=ricetta.measurement_interval_minutes,
cycle_count=n, started_at=inizio, closed_at=fine_run, closed_by=capoturno.id,
active_station_id=None,
)
session.add(run)
await session.flush()
session.add(ProductionEvent(run_id=run.id, event_type="start", user_id=operatore.id,
created_at=inizio))
for numero, minuti in enumerate(slot, start=1):
contatore += 1
seriale = f"{prefisso_seriale}{contatore:05d}"
istante = _ora_utc(giorno, minuti)
for marker in sorted(sottotask):
st = sottotask[marker]
_t, quota, scost, sigma, deriva = next(p for p in profilo if p[1]["marker_number"] == marker)
valore = _valore(rng, quota, scost, sigma, deriva, frazione)
esito, scarto = calculate_pass_fail(valore, st)
m = Measurement(
subtask_id=st.id, version_id=versione.id, measured_by=operatore.id,
value=valore, pass_fail=esito, deviation=scarto, lot_number=lotto,
serial_number=seriale,
input_method="usb_caliper" if rng.random() < 0.75 else "manual",
input_duration_ms=rng.randint(2500, 9000),
production_run_id=run.id,
# un fuori tolleranza passa solo con l'ok del capoturno (come in produzione)
supervisor_id=capoturno.id if esito == "fail" else None,
authorised_at=istante + timedelta(minutes=1) if esito == "fail" else None,
measured_at=istante + timedelta(seconds=20 * marker),
)
session.add(m)
totale += 1
# un task_measured per ogni task di misura non finale, poi il ciclo completato
for tid in ordine_task:
tipo = "cycle_completed" if tid == ultimo_task else "task_measured"
session.add(ProductionEvent(
run_id=run.id, event_type=tipo, user_id=operatore.id, task_id=tid,
created_at=istante + timedelta(minutes=1),
))
session.add(ProductionEvent(run_id=run.id, event_type="close", user_id=capoturno.id,
supervisor_id=capoturno.id, created_at=fine_run))
await session.flush()
return contatore, totale
async def _ricetta_dxf(session, owner, stazione):
"""Demo visione: un task dxf_compare, senza grafo (si crea in fiera dal pannello DXF)."""
ricetta, versione = await _nuova_ricetta(
session, owner, stazione, code="FIERA-DXF", name="Profilo DXF (visione)",
description="Confronto di un profilo con il disegno DXF tramite la camera. "
"La ricetta di visione si crea in fiera dal pannello «Crea ricetta da DXF».",
interval=None, requires_serial=False, note_versione="Ricetta dimostrativa per la fiera",
)
await _task(session, versione, 0, "Preparazione del pezzo", "note",
direttiva="Prima di misurare",
descrizione="Appoggiare il pezzo sul piano della camera, **in piano**.")
await _task(
session, versione, 1, "Confronto profilo con DXF", "dxf_compare",
direttiva="Acquisire e confrontare",
descrizione="Lo scostamento massimo del profilo rispetto al DXF deve restare entro la tolleranza.",
quote=[_quota(1, "Scostamento massimo profilo", 0.0, 0.15)],
)
return ricetta
async def seed(replace: bool, dry_run: bool, fine: date) -> int:
"""Esegue il seed in una sola transazione; con dry_run annulla tutto alla fine."""
rng = random.Random(SEED)
async with async_session_factory() as session:
utenti = await _upsert_utenti(session)
owner = utenti["adriano"]
stazione = await _upsert_stazione(session, owner)
esistenti = list((await session.execute(
select(Recipe).where(Recipe.code.in_(CODES))
)).scalars().all())
if esistenti and not replace:
print(f"Ricette FIERA gia' presenti ({', '.join(r.code for r in esistenti)}): "
"non duplico. Usa --replace per rifarle.")
else:
if esistenti:
conti = await _conta_e_cancella(session, esistenti)
print(f"--replace: rimosse {', '.join(r.code for r in esistenti)} "
f"({conti['misure']} misure, {conti['produzioni']} produzioni, "
f"{conti['eventi']} eventi)")
r1, v1, sub1 = await _ricetta_meccanica(
session, owner, stazione, code="FIERA-FLANGIA", name="Flangia Ø80",
description="Flangia tornita in alluminio: diametri, spessore, interasse fori "
"e profondita' della lamatura. Lotto e seriale obbligatori.",
interval=30, requires_serial=True, profilo=FLANGIA,
titoli_task=["Misure diametrali", "Spessore e posizione fori"],
)
n1, m1 = await _storico(session, rng, fine, owner, stazione, utenti, r1, v1, sub1,
FLANGIA, (10, 14), "LF", "FL")
r2, v2, sub2 = await _ricetta_meccanica(
session, owner, stazione, code="FIERA-ALBERO", name="Albero rettificato",
description="Albero in acciaio rettificato: sedi dei cuscinetti, lunghezza e "
"rugosita'. Tolleranze strette.",
interval=30, requires_serial=False, profilo=ALBERO,
titoli_task=["Diametri rettificati", "Lunghezza e rugosita'"],
)
n2, m2 = await _storico(session, rng, fine, owner, stazione, utenti, r2, v2, sub2,
ALBERO, (8, 11), "LA", "AL")
await _ricetta_dxf(session, owner, stazione)
print(f"FIERA-FLANGIA: {n1} pezzi, {m1} misure; FIERA-ALBERO: {n2} pezzi, {m2} misure")
if dry_run:
await session.rollback()
print("--dry-run: nessuna modifica salvata.")
else:
await session.commit()
print(f"Seed fiera completato (stazione {stazione.code}, utenti: "
f"{', '.join(u[0] for u in UTENTI)}).")
return 0
def _factory_per_file(db: Path):
"""Sessioni async su un file SQLite qualsiasi, con le stesse PRAGMA dell'app (FK attive)."""
engine = create_async_engine(f"sqlite+aiosqlite:///{db.resolve().as_posix()}", echo=False)
@event.listens_for(engine.sync_engine, "connect")
def _pragma(dbapi_connection, _record) -> None:
cur = dbapi_connection.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.execute("PRAGMA busy_timeout=5000")
cur.close()
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def _main_async(args) -> int:
global async_session_factory
engine, async_session_factory = _factory_per_file(args.db)
try:
fine = date.fromisoformat(args.fine) if args.fine else date.today() - timedelta(days=1)
return await seed(args.replace, args.dry_run, fine)
finally:
await engine.dispose()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--db", required=True, type=Path, help="file SQLite con lo schema gia' creato")
parser.add_argument("--replace", action="store_true",
help="rimuove i dati FIERA-* (ricette, misure, produzioni) e li rifa'")
parser.add_argument("--dry-run", action="store_true", help="esegue tutto e annulla")
parser.add_argument("--fine", default=None, help="ultimo giorno dello storico AAAA-MM-GG (default: ieri)")
args = parser.parse_args()
if not args.db.is_file():
print(f"File non trovato: {args.db}")
return 2
return asyncio.run(_main_async(args))
if __name__ == "__main__":
raise SystemExit(main())
+157
View File
@@ -0,0 +1,157 @@
"""Test di #94: «Blocca immagine» (scripts/camera_service.py) e riquadro vivo nella tab Calibrazione (PAGINA).
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgente finta che conta i grab: niente camera vera, niente ProgramData.
"""
import re
import numpy as np
import pytest
from test_calibrazione import _png, cartella, client, una_scacchiera # fixture e helper della calibrazione
from test_immagini_affiancate import _anteprima, _carica, _modulo
from test_pagina_acquisizione import PAGINA
__all__ = ["cartella", "client"] # le fixture importate restano usate
FINTA = "finta"
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
@pytest.fixture
def finta(client, monkeypatch):
"""Sorgente «finta»: il primo grab da' la scacchiera, i successivi un frame bianco (non calibrabile)."""
letture = []
scacchiera = una_scacchiera()
bianco = np.full(scacchiera.shape, 255, np.uint8)
class Finta:
def grab(self, timeout=1000):
letture.append(1)
return scacchiera.copy() if len(letture) == 1 else bianco.copy()
def close(self):
pass
monkeypatch.setitem(_modulo(client), "open_source", lambda *a, **k: Finta())
client.letture = letture
return client
def _blocca(c, nome=FINTA):
return c.post("/camera/blocca", json={"source": nome})
def _calcola(c, **extra):
return c.post("/camera/calibrate", data={"source": FINTA, **CAMPI, **extra})
# ---- servizio
def test_blocca_senza_frame_404(finta):
r = _blocca(finta)
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
def test_blocca_senza_source_400(finta):
assert finta.post("/camera/blocca", json={}).status_code == 400
assert finta.post("/camera/blocca").status_code == 400
def test_anteprima_poi_blocca_200_con_misure(finta):
assert _anteprima(finta, FINTA).status_code == 200
r = _blocca(finta)
alt, larg = una_scacchiera().shape[:2]
assert r.status_code == 200 and r.json() == {"source": FINTA, "larghezza": larg, "altezza": alt}
def test_calibrate_bloccata_usa_la_copia_senza_grab(finta):
assert _anteprima(finta, FINTA).status_code == 200 # frame 1: la scacchiera
assert _blocca(finta).status_code == 200
assert _anteprima(finta, FINTA).status_code == 200 # frame 2: bianco, il grezzo cambia ma la copia no
prima = len(finta.letture)
r = _calcola(finta, bloccata="true")
assert r.status_code == 200, r.text
assert r.json()["bloccata"] is True and r.json()["origine"] == FINTA
assert len(finta.letture) == prima # nessun grab per il calcolo
# senza il blocco il calcolo legge un frame nuovo (bianco): non calibrabile
assert _calcola(finta).status_code == 422
assert len(finta.letture) == prima + 1
def test_calibrate_normale_dice_bloccata_false(client):
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["bloccata"] is False
def test_calibrate_bloccata_senza_copia_409(finta):
assert _anteprima(finta, FINTA).status_code == 200 # il grezzo c'e', ma non e' bloccato
r = _calcola(finta, bloccata="true")
assert r.status_code == 409 and r.json()["detail"] == "nessuna immagine bloccata su questa sorgente"
def test_sblocca_204_idempotente_e_toglie_la_copia(finta):
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204 # niente da togliere
assert _anteprima(finta, FINTA).status_code == 200
assert _blocca(finta).status_code == 200
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
assert _calcola(finta, bloccata="true").status_code == 409
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
def test_rimuovere_l_immagine_toglie_la_copia_bloccata(client):
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
assert _anteprima(client, "foto.png").status_code == 200
assert _blocca(client, "foto.png").status_code == 200
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
r = client.post("/camera/calibrate", data={"source": "foto.png", "bloccata": "true", **CAMPI})
assert r.status_code == 409
# ---- pagina (contenuto statico)
def test_pagina_riquadro_e_casella_nella_tab_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"immagineCalTab", "vuotoCalTab", "scattaCal", "bloccaImg", "msgBlocco"} <= ids
assert "Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione" in PAGINA
assert "Blocca immagine" in PAGINA
tab = PAGINA.split('id="pannelloCal"')[1].split('id="sezioneSalvate"')[0]
assert "immagineCalTab" in tab and "bloccaImg" in tab # nella sezione Calibrazione, non altrove
def test_pagina_scegli_tab_non_ferma_piu_il_ciclo():
assert "A.riprendi" not in PAGINA
scegli = PAGINA.split("function scegliTab")[1].split("$(\"tabAcq\").onclick")[0]
assert "fermaAnteprima" not in scegli and "avviaAnteprima" not in scegli
def test_pagina_calcola_manda_bloccata_e_non_ferma_il_ciclo():
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert 'f.append("bloccata", "true")' in calcola
assert "if (bloccata)" in calcola and "else fermaAnteprima();" in calcola
assert "immagine bloccata" in calcola
assert "l'anteprima resta sospesa" not in PAGINA and "si può bloccare l'immagine" in PAGINA
def test_pagina_blocco_rotte_e_cambio_sorgente():
assert '"/camera/blocca"' in PAGINA and '"/camera/blocca?source="' in PAGINA
assert "method: \"DELETE\"" in PAGINA
cambio = PAGINA.split("function cambioSorgente")[1].split("// ---- Anteprima")[0]
assert "rilasciaBlocco()" in cambio
assert cambio.index("rilasciaBlocco()") < cambio.index("svuotaRiquadri()")
def test_pagina_blocco_attende_l_anteprima_in_volo():
# #94: il frame bloccato e' quello mostrato: flag A.bloccando, attesa di A.inVolo (20 ms, max 3 s), poi POST, flag tolto in finally
blocco = PAGINA.split('$("bloccaImg").onchange')[1].split('$("avvia").onclick')[0]
assert "A.bloccando++" in blocco and "A.bloccando--" in blocco and "finally" in blocco
assert "k < 150" in blocco and "pausa(20)" in blocco
assert blocco.index("A.inVolo") < blocco.index('fetch("/camera/blocca"')
ciclo = PAGINA.split("async function ciclo")[1].split("function avviaAnteprima")[0]
# #111: il lato dell'anteprima segue lo zoom (latoAnteprima(), 1280 senza zoom)
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(latoAnteprima())")
scatta = PAGINA.split("async function scatta")[1].split("// #94: «Blocca immagine»")[0]
assert "while (A.bloccando)" in scatta and scatta.index("A.bloccando") < scatta.index("unaAnteprima(4096)")
+422
View File
@@ -0,0 +1,422 @@
"""Test della calibrazione dell'acquisitore (#83): logica pura e rotte di camera_service.
Uso: dalla radice, `uv run --python 3.13 --extra vision-worker --extra dev
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q`
(o il venv di VisionSuite come per test_camera_service.py).
"""
import base64
import importlib.util
import json
from pathlib import Path
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
SCRIPTS = Path(__file__).resolve().parents[1]
MODULO = SCRIPTS / "camera_service.py"
def _carica():
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = 60) -> np.ndarray:
"""nx x ny quadrati => (nx-1) x (ny-1) angoli interni, su fondo bianco."""
img = np.full((ny * square_px + 2 * margin, nx * square_px + 2 * margin), 255, np.uint8)
for j in range(ny):
for i in range(nx):
if (i + j) % 2 == 0:
y, x = margin + j * square_px, margin + i * square_px
img[y:y + square_px, x:x + square_px] = 0
return img
def una_scacchiera_densa(w: int = 640, h: int = 480, square_px: int = 40, k: float = 1e-8) -> np.ndarray:
"""#84: scacchiera che riempie tutto il campo, con distorsione a barilotto (k sui px dal centro)."""
m = 3 * square_px # la scacchiera continua oltre il campo: nessun bordo finto nell'immagine
yy, xx = np.mgrid[0:h + 2 * m, 0:w + 2 * m]
piana = (((xx // square_px) + (yy // square_px)) % 2 * 255).astype(np.uint8)
cx, cy = w / 2.0, h / 2.0
dx, dy = (xx[m:m + h, m:m + w] - m - cx).astype(np.float32), (yy[m:m + h, m:m + w] - m - cy).astype(np.float32)
f = 1 + k * (dx * dx + dy * dy)
img = cv2.remap(piana, cx + m + dx * f, cy + m + dy * f, cv2.INTER_LINEAR)
return cv2.GaussianBlur(img, (3, 3), 0.8) # bordi non perfettamente netti, come in camera
def residuo_reticolo(griglia: np.ndarray) -> float:
"""Residuo massimo (px) della griglia (nr,nc,2) da un reticolo affine regolare."""
nr, nc, _ = griglia.shape
ii, jj = np.mgrid[0:nr, 0:nc]
A = np.stack([np.ones(nr * nc), ii.ravel(), jj.ravel()], axis=1)
P = griglia.reshape(-1, 2)
coef, *_ = np.linalg.lstsq(A, P, rcond=None)
return float(np.hypot(*(A @ coef - P).T).max())
def _png(img: np.ndarray) -> bytes:
ok, buf = cv2.imencode(".png", img)
assert ok
return buf.tobytes()
@pytest.fixture
def cartella(tmp_path):
# la sorgente camera:folder fa il loop su una scacchiera: il grab la trova
cartella = tmp_path / "immagini"
cartella.mkdir()
cv2.imwrite(str(cartella / "scacchiera.png"), una_scacchiera())
return cartella
@pytest.fixture
def client(cartella, tmp_path):
mod = _carica()
with TestClient(mod.create_app(cartella, config_dir=tmp_path / "config")) as c:
c.config = tmp_path / "config"
yield c
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
CHIAVI_C1 = {"px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows",
"square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"}
# #84: campi in piu' per metodo planare (C2 con cols/rows) e per la densa
CHIAVI_PLANARE = CHIAVI_C1 | {"metodo", "distorsione", "origine"} # #91: + origine ("camera:X" | "file:Y")
CHIAVI_DENSA = CHIAVI_PLANARE | {"residuo_rms_px", "residuo_max_px", "copertura_pct"}
SORGENTE = "camera:folder"
# #91: GET /camera/calibration = voce di libreria + "nome" + "source" (la sorgente che la usa)
CHIAVI_ATTIVA = {"source", "nome"}
def salva_con_nome(client, calcolo: dict, nome: str, **extra):
"""#91: POST /camera/calibrazioni sul calcolo_id di una risposta di /camera/calibrate."""
return client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": nome, **extra})
def carica_su(client, sorgente: str, nome: str):
"""#91: PUT /camera/calibrazione-attiva."""
return client.put("/camera/calibrazione-attiva", json={"source": sorgente, "nome": nome})
@pytest.fixture
def cartella_densa(tmp_path):
# #84: la sorgente camera:folder fa il loop su una scacchiera densa distorta
cartella = tmp_path / "immagini_dense"
cartella.mkdir()
cv2.imwrite(str(cartella / "densa.png"), una_scacchiera_densa(k=1e-7))
return cartella
@pytest.fixture
def client_denso(cartella_densa, tmp_path):
mod = _carica()
with TestClient(mod.create_app(cartella_densa, config_dir=tmp_path / "config_densa")) as c:
c.config = tmp_path / "config_densa"
yield c
NON_IMMAGINE = b"questo non e una immagine"
# ---- logica pura
def test_scacchiera_sintetica_da_la_scala():
_carica() # mette scripts/ in sys.path
import calibrazione
r = calibrazione.calibra_scacchiera(una_scacchiera(), 9, 6, 2.0)
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r["mm_per_px"] == pytest.approx(0.05, rel=0.005)
assert r["rms_px"] < 0.5
assert r["n_corners"] == 54
assert (r["image_width"], r["image_height"]) == (520, 400)
assert r["scale_spread_pct"] < 1.0
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
def test_immagine_bianca_solleva():
_carica()
import calibrazione
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
calibrazione.calibra_scacchiera(np.full((300, 300), 255, np.uint8), 9, 6, 2.0)
@pytest.mark.parametrize("cols,rows,quadrato", [(1, 6, 2.0), (9, 6, 0.0), (9, 6, -1.0)])
def test_parametri_invalidi_sollevano(cols, rows, quadrato):
_carica()
import calibrazione
with pytest.raises(calibrazione.CalibrazioneError):
calibrazione.calibra_scacchiera(una_scacchiera(), cols, rows, quadrato)
# ---- rotte
def test_c1_non_calibrata_404(client):
r = client.get("/camera/calibration", params={"source": "camera:folder"})
assert r.status_code == 404
assert r.json()["detail"] == "sorgente non calibrata"
def test_c1_senza_source_400(client):
assert client.get("/camera/calibration").status_code == 400
def test_c2_immagine_caricata_senza_salvare(client):
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
corpo = r.json()
assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id", "bloccata"} == set(corpo) # #94: + bloccata; #91: niente "saved", niente "source"
assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia
assert corpo["origine"] == "file:s.png"
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
# il calcolo non salva mai
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert not client.config.exists() or not list(client.config.iterdir())
def test_c2_save_true_viene_ignorato(client):
# #91: il vecchio campo save non salva piu' nulla
r = client.post("/camera/calibrate", data={"source": "camera:folder", "save": "true", **CAMPI})
assert r.status_code == 200, r.text
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
assert not client.config.exists() or not list(client.config.iterdir())
def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client):
# nessun file: grab da camera:folder (la scacchiera in cartella)
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["origine"] == "camera:folder"
assert salva_con_nome(client, r.json(), "Banco").status_code == 201 # #91: si salva con nome, dopo
# salvata ma non ancora caricata sulla sorgente
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert carica_su(client, "camera:folder", "Banco").status_code == 200
r1 = client.get("/camera/calibration", params={"source": "camera:folder"})
assert r1.status_code == 200
assert set(r1.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA # nessun overlay nel file salvato
assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r1.json()["source"] == "camera:folder" and r1.json()["nome"] == "Banco"
file_salvati = list((client.config / "calibrazioni").glob("*.json"))
assert len(file_salvati) == 1
assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9
# DELETE /camera/calibration scollega: la libreria resta
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert len(list((client.config / "calibrazioni").glob("*.json"))) == 1
# scollegare di nuovo e' idempotente
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
def test_c2_scacchiera_non_trovata_422(client):
bianca = np.full((300, 300), 255, np.uint8)
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
files={"image": ("b.png", _png(bianca), "image/png")})
assert r.status_code == 422
assert "non trovata" in r.json()["detail"]
def test_c2_parametri_invalidi_422(client):
r = client.post("/camera/calibrate",
data={"source": "camera:folder", "cols": "1", "rows": "6", "square_mm": "2"},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 422
def test_c2_immagine_illeggibile_422(client):
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
files={"image": ("x.png", NON_IMMAGINE, "image/png")})
assert r.status_code == 422
def test_c2_errori_camera_come_grab(client):
r = client.post("/camera/calibrate", data={"source": "camera:non-esiste", **CAMPI})
assert r.status_code == 404
def test_due_nomi_simili_hanno_file_diversi(client):
# #91: i nomi si ripuliscono come prima (hash sul nome originale): due nomi diversi non si pestano
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200
for nome in ("banco a b", "banco a_b"):
assert salva_con_nome(client, r.json(), nome).status_code == 201
assert len(list((client.config / "calibrazioni").glob("*.json"))) == 2
def test_c4_pagina_html(client):
r = client.get("/calibrazione")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
assert "Acquisisci e calibra" in r.text
assert "Lato dello scacco" in r.text and 'id="cols"' not in r.text # #84: un solo campo
assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN
# ---- #84: calibrazione densa (logica pura)
def test_densa_trova_la_griglia_da_sola_e_la_scala():
_carica()
import calibrazione
r, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(k=1e-8), 2.0)
assert (r["cols"], r["rows"]) == (15, 11) # 640x480 con quadrati da 40 px
assert r["n_corners"] == 15 * 11
assert r["mm_per_px"] == pytest.approx(2.0 / 40, rel=0.005)
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r["metodo"] == "densa" and r["distorsione"] is True
assert r["residuo_max_px"] < 0.3 and r["residuo_rms_px"] <= r["residuo_max_px"]
assert 60 < r["copertura_pct"] <= 100
assert (r["image_width"], r["image_height"]) == (640, 480)
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
assert mappa.shape == (480, 640) and mappa.pixel_size == pytest.approx(0.05, rel=0.005)
def test_densa_raddrizza_su_griglia_regolare():
_carica()
import calibrazione
img = una_scacchiera_densa(k=1e-7) # distorsione marcata: ai bordi ~5 %
r, mappa = calibrazione.calibra_scacchiera_densa(img, 2.0)
assert (r["cols"], r["rows"]) == (15, 11)
# prima la griglia e' curva, dopo e' un reticolo regolare
assert residuo_reticolo(calibrazione._trova_griglia(img)) > 1.0
pronta = calibrazione._prepara_mappa(mappa)
dritta = calibrazione.raddrizza(img, pronta)
assert dritta.shape == img.shape
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3
@pytest.mark.parametrize("cosa", ["bianca", "scacchiera_piccola"])
def test_densa_senza_griglia_solleva(cosa):
_carica()
import calibrazione
img = (np.full((300, 300), 255, np.uint8) if cosa == "bianca"
else una_scacchiera_densa(80, 60, 20)) # 3x2 angoli: meno di 3x3
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
calibrazione.calibra_scacchiera_densa(img, 2.0)
@pytest.mark.parametrize("quadrato", [0.0, -1.0, float("nan")])
def test_densa_quadrato_invalido_solleva(quadrato):
_carica()
import calibrazione
with pytest.raises(calibrazione.CalibrazioneError):
calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), quadrato)
def test_archivio_mappa_cache_e_invalidazione(tmp_path):
_carica()
import calibrazione
_, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)
arch = calibrazione.Archivio(tmp_path)
assert arch.leggi_mappa("s") is None
arch.salva_nome("cal", {"a": 1}, mappa) # #91: salvataggio con nome + attivazione sulla sorgente
arch.attiva("s", "cal")
prima = arch.leggi_mappa("s")
assert prima is not None and arch.leggi_mappa("s") is prima # cache in memoria
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
arch.salva_nome("cal", {"a": 2}, mappa, sovrascrivi=True) # file riscritto -> cache invalidata
assert arch.leggi_mappa("s") is not prima
arch.salva_nome("cal", {"a": 3}, sovrascrivi=True) # salvataggio senza mappa (planare): la vecchia sparisce
assert arch.leggi_mappa("s") is None and not list(tmp_path.rglob("*.npz"))
# ---- #84: rotte con la calibrazione densa
def _grigio(r) -> np.ndarray:
return cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
def test_c2_senza_cols_rows_e_densa(client_denso):
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert r.status_code == 200, r.text
corpo = r.json()
assert CHIAVI_DENSA | {"overlay_png", "calcolo_id", "bloccata"} == set(corpo)
assert corpo["metodo"] == "densa" and corpo["distorsione"] is True
assert (corpo["cols"], corpo["rows"]) == (15, 11)
assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"])
assert not client_denso.config.exists() or not list(client_denso.config.iterdir())
def test_c2_solo_cols_o_solo_rows_422(client_denso):
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "cols": "9"})
assert r.status_code == 422
def test_grab_raddrizzato_se_calibrata_e_grezzo_a_richiesta(client_denso, cartella_densa):
originale = cv2.imread(str(cartella_densa / "densa.png"), cv2.IMREAD_GRAYSCALE)
prima = client_denso.post("/camera/grab", json={"source": SORGENTE})
assert prima.headers["x-raddrizzato"] == "no" # non calibrata: frame com'e'
assert np.array_equal(_grigio(prima), originale)
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert r.status_code == 200, r.text
assert salva_con_nome(client_denso, r.json(), "Densa").status_code == 201 # #91: salvata con nome
libreria = client_denso.config / "calibrazioni"
json_salvati, mappe = list(libreria.glob("*.json")), list(libreria.glob("*.npz"))
assert len(json_salvati) == 1 and len(mappe) == 1
assert mappe[0].stem == json_salvati[0].stem # stesso nome del JSON
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" # salvata != attiva
assert carica_su(client_denso, SORGENTE, "Densa").status_code == 200
saved = client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()
assert set(saved) == CHIAVI_DENSA | CHIAVI_ATTIVA and saved["metodo"] == "densa"
dopo = client_denso.post("/camera/grab", json={"source": SORGENTE})
assert dopo.status_code == 200 and dopo.headers["x-raddrizzato"] == "si"
import calibrazione
dritta = _grigio(dopo)
assert dritta.shape == originale.shape
assert not np.array_equal(dritta, originale)
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3 # griglia regolare
grezzo = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": True})
assert grezzo.headers["x-raddrizzato"] == "no"
assert np.array_equal(_grigio(grezzo), originale)
# la calibrazione parte sempre dal frame grezzo: ricalibrare dalla sorgente da' lo stesso mm/px
di_nuovo = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert di_nuovo.json()["mm_per_px"] == pytest.approx(r.json()["mm_per_px"])
assert di_nuovo.json()["residuo_max_px"] < 0.3
def test_grab_grezzo_non_booleano_400(client_denso):
r = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": "si"})
assert r.status_code == 400
def test_scollega_lascia_la_libreria_ed_elimina_toglie_la_mappa(client_denso):
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
salva_con_nome(client_denso, r.json(), "Densa")
carica_su(client_denso, SORGENTE, "Densa")
libreria = client_denso.config / "calibrazioni"
assert list(libreria.glob("*.npz"))
# scollegare: il frame torna com'e', ma la voce (e la mappa) restano in libreria
assert client_denso.delete("/camera/calibration", params={"source": SORGENTE}).status_code == 204
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
assert list(libreria.glob("*.npz")) and list(libreria.glob("*.json"))
# eliminare la voce toglie JSON e mappa
carica_su(client_denso, SORGENTE, "Densa")
assert client_denso.delete("/camera/calibrazioni", params={"nome": "Densa"}).status_code == 204
assert not list(libreria.glob("*.npz")) and not list(libreria.glob("*.json"))
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
def test_ricalibrare_planare_toglie_la_mappa_densa(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
salva_con_nome(client_denso, densa.json(), "Cal")
carica_su(client_denso, SORGENTE, "Cal")
libreria = client_denso.config / "calibrazioni"
assert list(libreria.glob("*.npz"))
planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert planare.status_code == 200 and planare.json()["metodo"] == "planare"
assert salva_con_nome(client_denso, planare.json(), "Cal").status_code == 409 # nome gia' preso
assert salva_con_nome(client_denso, planare.json(), "Cal", sovrascrivi=True).status_code == 201
assert not list(libreria.glob("*.npz")) # niente mappa vecchia che raddrizza a vuoto
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
+148
View File
@@ -0,0 +1,148 @@
"""Test di #95: calibrazione manuale (mm/px impostato a mano) in scripts/camera_service.py e nella PAGINA.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgenti = immagini caricate in tmp_path: niente camera vera, niente ProgramData.
"""
import re
import numpy as np
import pytest
from test_calibrazione import _png, cartella, client # fixture della calibrazione
from test_immagini_affiancate import _anteprima, _calibrata, _carica
from test_pagina_acquisizione import PAGINA
__all__ = ["cartella", "client"] # le fixture importate restano usate
SRC = "banco.png" # 64x48
ALTRA = "grande.png" # 80x60, risoluzione diversa
@pytest.fixture
def sorgenti(client):
assert _carica(client, SRC, np.full((48, 64), 120, np.uint8)).status_code == 201
assert _carica(client, ALTRA, np.full((60, 80), 120, np.uint8)).status_code == 201
return client
def _manuale(c, nome="Manuale", mm=0.05, source=SRC, **extra):
return c.post("/camera/calibrazioni/manuale",
json={"source": source, "nome": nome, "mm_per_px": mm, **extra})
# ---- servizio
def test_manuale_201_con_scala_e_risoluzione_del_frame(sorgenti):
r = _manuale(sorgenti, mm=0.05)
assert r.status_code == 201, r.text
m = r.json()
assert m["nome"] == "Manuale" and m["metodo"] == "manuale"
assert m["px_per_mm"] == pytest.approx(20.0) and m["mm_per_px"] == pytest.approx(0.05)
assert (m["image_width"], m["image_height"]) == (64, 48)
assert m["origine"] == SRC and m["square_mm"] is None and m["attiva_su"] == [] # non la attiva
voci = sorgenti.get("/camera/calibrazioni").json()["calibrazioni"]
assert [v["nome"] for v in voci] == ["Manuale"]
def test_manuale_usa_il_frame_tenuto_se_c_e(sorgenti):
assert _anteprima(sorgenti, SRC).status_code == 200 # tiene il grezzo 64x48
r = _manuale(sorgenti)
assert r.status_code == 201 and (r.json()["image_width"], r.json()["image_height"]) == (64, 48)
@pytest.mark.parametrize("valore", [0, -1, 0.000001, 100.5, "abc", None, True])
def test_manuale_mm_non_valido_400(sorgenti, valore):
r = _manuale(sorgenti, mm=valore)
assert r.status_code == 400
assert "mm/px deve essere un numero positivo" in r.json()["detail"]
def test_manuale_senza_source_o_nome_400_e_sorgente_ignota_404(sorgenti):
assert sorgenti.post("/camera/calibrazioni/manuale", json={"nome": "x", "mm_per_px": 0.1}).status_code == 400
assert _manuale(sorgenti, nome=" ").status_code == 400
assert _manuale(sorgenti, source="nonesiste.png").status_code == 404
def test_manuale_409_nome_doppio_e_sovrascrivi(sorgenti):
assert _manuale(sorgenti, mm=0.05).status_code == 201
r = _manuale(sorgenti, mm=0.1)
assert r.status_code == 409 and "esiste già una calibrazione" in r.json()["detail"]
r = _manuale(sorgenti, mm=0.1, sovrascrivi=True)
assert r.status_code == 201 and r.json()["px_per_mm"] == pytest.approx(10.0)
assert _manuale(sorgenti, mm=0.1, sovrascrivi="si").status_code == 400
def test_attiva_manuale_ok_su_stessa_risoluzione_e_legge_px_per_mm(sorgenti):
assert _manuale(sorgenti, mm=0.05).status_code == 201
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"})
assert r.status_code == 200, r.text
g = sorgenti.get("/camera/calibration", params={"source": SRC})
assert g.status_code == 200 and g.json()["px_per_mm"] == pytest.approx(20.0)
assert g.json()["nome"] == "Manuale" and g.json()["metodo"] == "manuale"
def test_attiva_manuale_409_su_risoluzione_diversa(sorgenti):
assert _manuale(sorgenti).status_code == 201
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": ALTRA, "nome": "Manuale"})
assert r.status_code == 409 and "64×48" in r.json()["detail"]
def test_anteprima_calibrata_con_manuale_200_grezza(sorgenti):
assert _manuale(sorgenti).status_code == 201
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
assert _anteprima(sorgenti, SRC).status_code == 200
r = _calibrata(sorgenti, SRC)
assert r.status_code == 200, r.text
assert r.headers["X-Raddrizzato"] == "no" and r.headers["X-Calibrazione"] == "Manuale"
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("64", "48")
assert r.headers["content-type"] == "image/jpeg"
def test_anteprima_calibrata_manuale_409_se_il_frame_ha_altra_risoluzione(sorgenti):
assert _manuale(sorgenti).status_code == 201
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
assert _anteprima(sorgenti, ALTRA).status_code == 200
# stessa sorgente attiva, ma il frame tenuto cambia (immagine sostituita con altra misura)
assert _carica(sorgenti, SRC, np.full((60, 80), 9, np.uint8)).status_code == 201
assert _anteprima(sorgenti, SRC).status_code == 200
r = _calibrata(sorgenti, SRC)
assert r.status_code == 409 and "64×48" in r.json()["detail"]
# ---- pagina
def test_pagina_scheda_manuale_id_e_testi():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"mmPx", "pxMmCalc", "nomeManuale", "salvaManuale", "msgManuale"} <= ids
tab = PAGINA.split('id="pannelloCal"')[1]
assert tab.index('id="sezioneCalibrazione"') < tab.index('id="sezioneManuale"') < tab.index('id="sezioneSalvate"')
assert "Calibrazione manuale" in PAGINA and "mm per pixel" in PAGINA
assert "salvata: per usarla «Carica» o CONFERMA" in PAGINA
assert "Sovrascrivi «" in PAGINA.split("async function salvaManuale")[1] # stessa conferma del Salva scacchiera
assert "/camera/calibrazioni/manuale" in PAGINA and "alert(" not in PAGINA and "confirm(" not in PAGINA
def test_pagina_manuale_senza_sorgente_pulsante_spento_con_nota():
stato = PAGINA.split("function statoManuale")[1].split("function aggiornaManuale")[0]
assert 'salvaManuale").disabled = !s' in stato and "Scegliere una sorgente" in stato
assert "statoManuale()" in PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
def test_pagina_px_mm_calcolato_a_ogni_tasto():
assert '$("mmPx").oninput = aggiornaManuale' in PAGINA
assert '"= " + fmt(1 / v, 4) + " px/mm"' in PAGINA
def test_pagina_metodo_manuale_e_campi_null():
assert 'c.metodo === "densa" || c.metodo === "manuale" ? c.metodo : "planare"' in PAGINA
righe = PAGINA.split("function righe")[1].split("function tabella")[0]
assert 'd.metodo === "manuale"' in righe and "Lato dello scacco" in righe # la manuale ha il suo elenco, le altre il solito
assert 'v === null || v === undefined' in PAGINA.split("function fmt")[1].split("function fmtCasella")[0]
stato = PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
assert "Number(d.square_mm) > 0" in stato
def test_pagina_calibrata_senza_raddrizzamento_dice_solo_scala():
assert "solo scala, nessuna correzione ottica" in PAGINA
assert 'r.headers.get("X-Raddrizzato") === "no"' in PAGINA
+233
View File
@@ -0,0 +1,233 @@
"""Test dell'archivio unico di calibrazioni con nome (#91): scripts/calibrazione.py, classe Archivio.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Solo tmp_path: niente ProgramData, niente camera.
"""
import json
import pytest
from test_calibrazione import _carica, una_scacchiera_densa
@pytest.fixture(scope="module")
def mappa_densa():
# #91: una mappa vera, calcolata una volta sola per tutti i test del modulo
_carica()
import calibrazione
return calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)[1]
def _dati(**extra):
base = {"px_per_mm": 20.0, "mm_per_px": 0.05, "image_width": 640, "image_height": 480,
"metodo": "planare", "square_mm": 2.0, "calibrated_at": "2026-10-24T10:00:00+00:00",
"origine": "file:s.png"}
return {**base, **extra}
@pytest.fixture
def cal():
_carica()
import calibrazione
return calibrazione
# ---- nomi
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x00b", "a\nb", "a\tb", None, 5])
def test_nome_non_valido(cal, tmp_path, nome):
with pytest.raises(cal.NomeNonValido):
cal.Archivio(tmp_path).salva_nome(nome, _dati())
def test_nome_valido_limiti_e_strip(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("x" * 60, _dati())
meta = arch.salva_nome(" Banco 1 ", _dati())
assert meta["nome"] == "Banco 1" # strip
assert arch.leggi_nome("Banco 1")["nome"] == "Banco 1"
def test_nome_unico_senza_maiuscole(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
with pytest.raises(cal.NomeGiaUsato, match="Banco"):
arch.salva_nome("BANCO", _dati(px_per_mm=2.0))
assert arch.leggi_nome("banco")["px_per_mm"] == 1.0 # non toccata
assert len(arch.elenco()) == 1
def test_sovrascrivi_sostituisce_e_prende_il_nuovo_nome(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
arch.salva_nome("BANCO", _dati(px_per_mm=2.0), sovrascrivi=True)
voci = arch.elenco()
assert len(voci) == 1 and voci[0]["nome"] == "BANCO" and voci[0]["px_per_mm"] == 2.0
assert len(list((tmp_path / "calibrazioni").glob("*.json"))) == 1
def test_salva_non_scrive_source_ne_overlay(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("A", _dati(source="camera:x", overlay_png="abc", saved=True))
scritto = json.loads(next((tmp_path / "calibrazioni").glob("*.json")).read_text(encoding="utf-8"))
assert "source" not in scritto and "overlay_png" not in scritto and "saved" not in scritto
assert scritto["nome"] == "A" and scritto["origine"] == "file:s.png"
def test_leggi_nome_inesistente_o_non_valido(cal, tmp_path):
arch = cal.Archivio(tmp_path)
assert arch.leggi_nome("nessuna") is None and arch.leggi_nome("") is None
# ---- elenco e attive
def test_elenco_dalla_piu_recente_e_attiva_su(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("vecchia", _dati(calibrated_at="2026-01-01T00:00:00+00:00"))
arch.salva_nome("nuova", _dati(calibrated_at="2026-10-01T00:00:00+00:00"))
arch.attiva("camera:b", "nuova")
arch.attiva("camera:a", "nuova")
voci = arch.elenco()
assert [v["nome"] for v in voci] == ["nuova", "vecchia"]
assert voci[0]["attiva_su"] == ["camera:a", "camera:b"] and voci[1]["attiva_su"] == []
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm",
"mm_per_px", "metodo", "square_mm", "calibrated_at", "attiva_su"}
def test_attiva_leggi_scollega(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Banco", _dati())
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
assert arch.attiva("camera:a", "banco") == "Banco" # nome come in libreria
assert arch.attiva_di("camera:a") == "Banco"
letta = arch.leggi("camera:a")
assert letta["nome"] == "Banco" and letta["source"] == "camera:a" and letta["px_per_mm"] == 20.0
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:a": "Banco"}
arch.scollega("camera:a")
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
assert arch.leggi_nome("Banco") is not None # la libreria resta
arch.scollega("camera:a") # idempotente
def test_attiva_nome_sconosciuto(cal, tmp_path):
with pytest.raises(cal.NomeSconosciuto):
cal.Archivio(tmp_path).attiva("camera:a", "nessuna")
def test_due_sorgenti_stessa_calibrazione(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Comune", _dati())
arch.attiva("camera:a", "Comune")
arch.attiva("camera:b", "Comune")
assert arch.leggi("camera:a")["source"] == "camera:a" and arch.leggi("camera:b")["source"] == "camera:b"
def test_elimina_nome_toglie_da_attive_e_mappa(cal, tmp_path, mappa_densa):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
arch.salva_nome("Altra", _dati())
arch.attiva("camera:a", "Densa")
arch.attiva("camera:b", "Densa")
arch.attiva("camera:c", "Altra")
assert arch.leggi_mappa("camera:a") is not None
assert arch.elimina_nome("DENSA") is True
assert arch.leggi_nome("Densa") is None and not list((tmp_path / "calibrazioni").glob("*.npz"))
assert arch.attiva_di("camera:a") is None and arch.attiva_di("camera:b") is None
assert arch.leggi_mappa("camera:a") is None
assert arch.attiva_di("camera:c") == "Altra"
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:c": "Altra"}
assert arch.elimina_nome("Densa") is False # gia' tolta
# ---- mappa
def test_leggi_mappa_risolve_l_attiva_con_cache(cal, tmp_path, mappa_densa):
arch = cal.Archivio(tmp_path)
assert arch.leggi_mappa("camera:a") is None
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
assert arch.leggi_mappa("camera:a") is None # non attiva su camera:a
arch.attiva("camera:a", "Densa")
prima = arch.leggi_mappa("camera:a")
assert prima is not None and arch.leggi_mappa("camera:a") is prima # cache in memoria
assert arch.leggi_mappa("camera:b") is None
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa, sovrascrivi=True) # file riscritto
assert arch.leggi_mappa("camera:a") is not prima # cache invalidata
arch.salva_nome("Densa", _dati(), sovrascrivi=True) # sovrascritta con una planare: la mappa sparisce
assert arch.leggi_mappa("camera:a") is None and not list(tmp_path.rglob("*.npz"))
# ---- migrazione dal formato vecchio
def _vecchia(cartella, cal, source, mappa=None, **extra):
"""Scrive una calibrazione nel formato vecchio (un JSON per sorgente, + .npz) come faceva Archivio.salva."""
dati = {"source": source, **_dati(**extra)}
dati.pop("origine")
nudo = cal.Archivio.__new__(cal.Archivio) # senza costruttore: niente migrazione mentre si prepara
nudo.cartella = cartella
percorso = nudo._file(source)
cartella.mkdir(parents=True, exist_ok=True)
percorso.write_text(json.dumps(dati), encoding="utf-8")
if mappa is not None:
mappa.save(str(percorso.with_suffix(".npz")))
return percorso
def test_migrazione_formato_vecchio(cal, tmp_path, mappa_densa):
v1 = _vecchia(tmp_path, cal, "camera:601020", mappa_densa, metodo="densa", px_per_mm=7.0)
v2 = _vecchia(tmp_path, cal, "camera:folder", px_per_mm=9.0)
(tmp_path / "settaggi").mkdir()
(tmp_path / "settaggi" / v1.name).write_text("{}", encoding="utf-8") # non si tocca
arch = cal.Archivio(tmp_path)
assert not v1.exists() and not v1.with_suffix(".npz").exists() and not v2.exists()
assert {v["nome"] for v in arch.elenco()} == {"camera:601020", "camera:folder"}
assert arch.attiva_di("camera:601020") == "camera:601020" and arch.attiva_di("camera:folder") == "camera:folder"
letta = arch.leggi("camera:601020")
assert letta["px_per_mm"] == 7.0 and letta["source"] == "camera:601020"
assert arch.leggi_mappa("camera:601020") is not None # il .npz ha seguito il JSON
assert len(list((tmp_path / "calibrazioni").glob("*.npz"))) == 1
assert (tmp_path / "settaggi" / v1.name).read_text(encoding="utf-8") == "{}"
def test_migrazione_idempotente(cal, tmp_path):
_vecchia(tmp_path, cal, "camera:a")
cal.Archivio(tmp_path)
prima = sorted(p.name for p in tmp_path.rglob("*") if p.is_file())
arch = cal.Archivio(tmp_path) # secondo avvio
arch.migra()
assert sorted(p.name for p in tmp_path.rglob("*") if p.is_file()) == prima
assert [v["nome"] for v in arch.elenco()] == ["camera:a"]
def test_migrazione_nome_gia_preso(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("camera:a", _dati(px_per_mm=1.0, calibrated_at="2020-01-01T00:00:00+00:00"))
_vecchia(tmp_path, cal, "camera:a", px_per_mm=5.0)
arch.migra()
nomi = {v["nome"]: v["px_per_mm"] for v in arch.elenco()}
assert nomi == {"camera:a": 1.0, "camera:a (2)": 5.0}
assert arch.attiva_di("camera:a") == "camera:a (2)" # la sorgente usa la sua, non quella gia' presente
# un altro file con lo stesso nome -> (3)
_vecchia(tmp_path, cal, "camera:A", px_per_mm=6.0) # nome uguale senza maiuscole
arch.migra()
assert "camera:A (3)" in {v["nome"] for v in arch.elenco()}
def test_migrazione_ignora_json_senza_source_e_attive(cal, tmp_path):
tmp_path.mkdir(exist_ok=True)
(tmp_path / "altro.json").write_text(json.dumps({"x": 1}), encoding="utf-8")
(tmp_path / "rotto.json").write_text("{non json", encoding="utf-8")
arch = cal.Archivio(tmp_path)
arch.salva_nome("A", _dati())
arch.attiva("source", "A") # una sorgente chiamata «source» non deve far scambiare attive.json per un vecchio file
arch.migra()
assert (tmp_path / "altro.json").exists() and (tmp_path / "rotto.json").exists()
assert arch.attiva_di("source") == "A" and [v["nome"] for v in arch.elenco()] == ["A"]
def test_cartella_assente_non_da_errore(cal, tmp_path):
arch = cal.Archivio(tmp_path / "non" / "esiste")
assert arch.elenco() == [] and arch.leggi("s") is None and arch.leggi_mappa("s") is None
arch.scollega("s")
assert arch.elimina_nome("x") is False
+250
View File
@@ -0,0 +1,250 @@
"""Test delle rotte dell'archivio unico di calibrazioni con nome (#91): scripts/camera_service.py.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgenti: camera:folder su cartelle temporanee (520x400 scacchiera piana, 640x480 densa).
Solo tmp_path: niente ProgramData, niente camera vera.
"""
import json
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
CAMPI, CHIAVI_ATTIVA, CHIAVI_DENSA, CHIAVI_PLANARE, SORGENTE, _carica, _png,
carica_su, cartella, cartella_densa, client, client_denso, salva_con_nome,
una_scacchiera, una_scacchiera_densa,
)
__all__ = ["cartella", "cartella_densa", "client", "client_denso"] # le fixture importate restano usate
def _calcola_file(c, **extra):
"""Calcolo planare da un'immagine (520x400): ritorna la risposta JSON."""
r = c.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI, **extra},
files={"image": ("scacco.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
return r.json()
# ---- POST /camera/calibrate
def test_calibrate_senza_source_con_immagine_ok(client):
r = client.post("/camera/calibrate", data=CAMPI,
files={"image": ("C:\\foto\\scacco.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
assert r.json()["origine"] == "file:scacco.png" # solo il nome del file, non il percorso
def test_calibrate_senza_source_e_senza_immagine_400(client):
r = client.post("/camera/calibrate", data=CAMPI)
assert r.status_code == 400 and "source" in r.json()["detail"]
def test_calibrate_da_sorgente_ha_origine_camera_e_id(client):
r = client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI})
assert r.status_code == 200, r.text
corpo = r.json()
assert corpo["origine"] == SORGENTE and corpo["calibrated_at"]
assert len(corpo["calcolo_id"]) == 32 and int(corpo["calcolo_id"], 16) >= 0 # esadecimale
assert corpo["calcolo_id"] != client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI}).json()["calcolo_id"]
# ---- POST /camera/calibrazioni
def test_salva_201_con_metadati(client):
calcolo = _calcola_file(client)
r = salva_con_nome(client, calcolo, " Banco 1 ")
assert r.status_code == 201, r.text
meta = r.json()
assert meta["nome"] == "Banco 1" and meta["origine"] == "file:scacco.png"
assert (meta["image_width"], meta["image_height"]) == (520, 400)
assert meta["metodo"] == "planare" and meta["attiva_su"] == []
assert meta["px_per_mm"] == pytest.approx(20.0, rel=0.005)
# lo stesso calcolo si puo' salvare anche con un altro nome
assert salva_con_nome(client, calcolo, "Banco 2").status_code == 201
def test_salva_calcolo_sconosciuto_404(client):
r = client.post("/camera/calibrazioni", json={"calcolo_id": "0" * 32, "nome": "X"})
assert r.status_code == 404 and r.json()["detail"] == "calcolo non più disponibile: ricalcolare"
def test_salva_calcolo_scaduto_dopo_otto_calcoli_404(client):
primo = _calcola_file(client)
ultimo = primo
for _ in range(8): # il nono calcolo spinge fuori il primo
ultimo = _calcola_file(client)
assert salva_con_nome(client, primo, "Vecchio").status_code == 404
assert salva_con_nome(client, ultimo, "Nuovo").status_code == 201
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x01b", None, 7])
def test_salva_nome_non_valido_400(client, nome):
r = client.post("/camera/calibrazioni", json={"calcolo_id": _calcola_file(client)["calcolo_id"], "nome": nome})
assert r.status_code == 400, r.text
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
def test_salva_corpo_incompleto_400(client):
assert client.post("/camera/calibrazioni", json={"nome": "X"}).status_code == 400
calcolo = _calcola_file(client)
r = client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": "X", "sovrascrivi": "si"})
assert r.status_code == 400
def test_salva_nome_gia_usato_409_e_sovrascrivi(client):
a = _calcola_file(client)
assert salva_con_nome(client, a, "Banco").status_code == 201
r = salva_con_nome(client, a, "BANCO") # senza distinguere maiuscole
assert r.status_code == 409 and r.json()["detail"] == "esiste già una calibrazione «Banco»"
assert len(client.get("/camera/calibrazioni").json()["calibrazioni"]) == 1
r = salva_con_nome(client, a, "BANCO", sovrascrivi=True)
assert r.status_code == 201 and r.json()["nome"] == "BANCO"
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
assert [v["nome"] for v in voci] == ["BANCO"]
# ---- GET /camera/calibrazioni
def test_elenco_vuoto_e_ordine(client):
assert client.get("/camera/calibrazioni").json() == {"calibrazioni": [], "attiva": None}
prima = _calcola_file(client)
salva_con_nome(client, prima, "prima")
seconda = _calcola_file(client)
seconda_ts = seconda["calibrated_at"]
salva_con_nome(client, seconda, "seconda")
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
assert {v["nome"] for v in voci} == {"prima", "seconda"}
assert [v["calibrated_at"] for v in voci] == sorted((v["calibrated_at"] for v in voci), reverse=True)
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm", "mm_per_px",
"metodo", "square_mm", "calibrated_at", "attiva_su"}
assert seconda_ts >= prima["calibrated_at"]
def test_elenco_con_source_dice_l_attiva(client):
salva_con_nome(client, _calcola_file(client), "Banco")
salva_con_nome(client, _calcola_file(client), "Altra")
assert carica_su(client, SORGENTE, "banco").status_code == 200
r = client.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
assert r["attiva"] == "Banco"
attiva_su = {v["nome"]: v["attiva_su"] for v in r["calibrazioni"]}
assert attiva_su == {"Banco": [SORGENTE], "Altra": []}
assert client.get("/camera/calibrazioni", params={"source": "camera:altra"}).json()["attiva"] is None
assert client.get("/camera/calibrazioni", params={"source": " "}).status_code == 400
# ---- DELETE /camera/calibrazioni
def test_elimina_204_404_e_toglie_dalle_sorgenti(client):
salva_con_nome(client, _calcola_file(client), "Banco")
carica_su(client, SORGENTE, "Banco")
assert client.delete("/camera/calibrazioni", params={"nome": "BANCO"}).status_code == 204
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
assert client.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # non piu' attiva
assert json.loads((client.config / "attive.json").read_text(encoding="utf-8")) == {}
assert client.delete("/camera/calibrazioni", params={"nome": "Banco"}).status_code == 404
assert client.delete("/camera/calibrazioni").status_code == 404 # senza nome
# ---- PUT /camera/calibrazione-attiva
def test_carica_risoluzione_uguale_200_come_get(client):
salva_con_nome(client, _calcola_file(client), "Banco") # 520x400 = la scacchiera in cartella
r = carica_su(client, SORGENTE, "banco")
assert r.status_code == 200, r.text
assert set(r.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA
assert r.json()["nome"] == "Banco" and r.json()["source"] == SORGENTE
assert client.get("/camera/calibration", params={"source": SORGENTE}).json() == r.json()
def test_carica_risoluzione_diversa_409_e_anteprima_continua(client_denso):
# calibrazione da file 520x400, sorgente 640x480
salva_con_nome(client_denso, _calcola_file(client_denso), "Piccola")
r = carica_su(client_denso, SORGENTE, "Piccola")
assert r.status_code == 409
assert r.json()["detail"] == ("la calibrazione «Piccola» è per immagini 520×400, "
"la sorgente dà 640×480: non si può caricare")
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # nulla attivato
# l'acquisizione continua: grab e anteprima rispondono, frame non raddrizzato
g = client_denso.post("/camera/grab", json={"source": SORGENTE})
assert g.status_code == 200 and g.headers["x-raddrizzato"] == "no"
a = client_denso.post("/camera/anteprima", json={"source": SORGENTE})
assert a.status_code == 200 and a.headers["x-raddrizzato"] == "no"
assert (a.headers["x-larghezza"], a.headers["x-altezza"]) == ("640", "480")
def test_carica_risoluzione_diversa_non_scalza_l_attiva_precedente(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
salva_con_nome(client_denso, densa, "Giusta")
salva_con_nome(client_denso, _calcola_file(client_denso), "Sbagliata")
assert carica_su(client_denso, SORGENTE, "Giusta").status_code == 200
assert carica_su(client_denso, SORGENTE, "Sbagliata").status_code == 409
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()["nome"] == "Giusta"
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
def test_carica_densa_su_sorgente_stessa_risoluzione_raddrizza(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
r = carica_su(client_denso, SORGENTE, "Densa")
assert r.status_code == 200 and set(r.json()) == CHIAVI_DENSA | CHIAVI_ATTIVA
assert client_denso.post("/camera/anteprima", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
def test_carica_nome_sconosciuto_404(client):
r = carica_su(client, SORGENTE, "nessuna")
assert r.status_code == 404
assert client.put("/camera/calibrazione-attiva", json={"source": SORGENTE}).status_code == 404 # senza nome
assert client.put("/camera/calibrazione-attiva", json={"nome": "x"}).status_code == 400 # senza source
def test_carica_su_sorgente_inesistente_404(client):
salva_con_nome(client, _calcola_file(client), "Banco")
assert carica_su(client, "camera:non-esiste", "Banco").status_code == 404
def test_carica_senza_risoluzione_registrata_409(cartella, tmp_path):
_carica()
import calibrazione
config = tmp_path / "config_senza"
calibrazione.Archivio(config).salva_nome("Senza", {"px_per_mm": 10.0, "calibrated_at": "2026-01-01T00:00:00+00:00"})
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
r = carica_su(c, SORGENTE, "Senza")
assert r.status_code == 409
assert r.json()["detail"] == "la calibrazione «Senza» non ha la risoluzione registrata: non si può caricare"
# ---- contratto verso il backend e migrazione all'avvio
def test_get_calibration_contratto_per_il_backend(client):
# src/backend/services/vision_service.py legge questi campi
salva_con_nome(client, _calcola_file(client), "Banco")
carica_su(client, SORGENTE, "Banco")
dati = client.get("/camera/calibration", params={"source": SORGENTE}).json()
for campo in ("px_per_mm", "mm_per_px", "image_width", "image_height", "calibrated_at", "metodo"):
assert campo in dati
assert client.get("/camera/calibration", params={"source": "camera:altra"}).json() == {
"detail": "sorgente non calibrata"}
def test_avvio_migra_il_formato_vecchio(cartella, tmp_path):
_carica()
import calibrazione
config = tmp_path / "config_vecchia"
config.mkdir()
nudo = calibrazione.Archivio.__new__(calibrazione.Archivio) # per avere il nome file del formato vecchio
nudo.cartella = config
vecchio = nudo._file(SORGENTE)
vecchio.write_text(json.dumps({"source": SORGENTE, "px_per_mm": 20.0, "mm_per_px": 0.05,
"image_width": 520, "image_height": 400, "metodo": "planare",
"calibrated_at": "2026-10-01T10:00:00+00:00"}), encoding="utf-8")
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
dati = c.get("/camera/calibration", params={"source": SORGENTE}).json()
assert dati["nome"] == SORGENTE and dati["source"] == SORGENTE and dati["px_per_mm"] == 20.0
voci = c.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
assert [v["nome"] for v in voci["calibrazioni"]] == [SORGENTE] and voci["attiva"] == SORGENTE
assert voci["calibrazioni"][0]["attiva_su"] == [SORGENTE]
assert not vecchio.exists()
+320
View File
@@ -0,0 +1,320 @@
"""Test delle rotte di acquisizione di scripts/camera_service.py (#87).
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Copre: POST /camera/anteprima, GET/PUT /camera/parametri, POST /camera/parametri/salva,
caricamento dei settaggi alla riapertura, alias GET /acquisizione.
Sorgente regolabile finta (iniettata su open_source) e FolderCamera su PNG temporanei.
"""
import importlib.util
import json
from pathlib import Path
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
from visionsuite.camera.camera_interface import CameraStateError
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
SORGENTE = "camera:finta"
def _carica():
spec = importlib.util.spec_from_file_location("camera_service_acq", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class Finta:
"""Camera regolabile finta: stesso vocabolario di GenicamSource."""
DOMINI = {
"esposizione": {"tipo": "numero", "min": 10.0, "max": 100000.0, "passo": 1.0},
"esposizione_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
"guadagno": {"tipo": "numero", "min": 0.0, "max": 24.0},
"guadagno_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
}
def __init__(self, supportati=None, rifiuta=()):
self.valori = {"esposizione": 5000.0, "esposizione_auto": "Continuous",
"guadagno": 1.0, "guadagno_auto": "Off"}
self.supportati = set(supportati if supportati is not None else self.DOMINI)
self.rifiuta = set(rifiuta)
self.scritture = []
self.caricato = None
self.chiusa = False
# metà bianca (255) e metà a 100: saturati 50%, media 177,5
frame = np.full((1000, 2000, 3), 100, np.uint8)
frame[:, :1000] = 255
self.frame = frame
def supporta(self, nome):
return nome in self.supportati
def dominio(self, nome):
return {"capacita": nome, "valore": self.valori[nome], **self.DOMINI[nome]}
def leggi(self, nome):
return self.valori[nome]
def scrivi(self, nome, valore, assesta=0):
if nome in self.rifiuta:
raise CameraStateError(f"non posso impostare {nome}: nodo in sola lettura")
self.scritture.append((nome, valore, assesta))
self.valori[nome] = valore
def salva_settaggi(self, percorso):
Path(percorso).write_text(json.dumps(self.valori), encoding="utf-8")
return dict(self.valori)
def carica_settaggi(self, percorso, forza=False):
self.caricato = Path(percorso)
self.valori.update(json.loads(self.caricato.read_text(encoding="utf-8")))
return []
def grab(self, timeout=0):
return self.frame.copy()
def close(self):
self.chiusa = True
@pytest.fixture
def cartella(tmp_path):
img = tmp_path / "foto"
img.mkdir()
for nome in ("a.png", "b.png"):
cv2.imwrite(str(img / nome), np.full((48, 64, 3), 128, np.uint8))
return img
def _client(cartella, config, monkeypatch, finta):
mod = _carica()
monkeypatch.setattr(mod, "open_source", lambda *a, **k: finta)
c = TestClient(mod.create_app(cartella, config))
c.mod = mod
return c
@pytest.fixture
def finta():
return Finta()
@pytest.fixture
def client(cartella, tmp_path, monkeypatch, finta):
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
c.finta = finta
c.config = tmp_path / "cfg"
yield c
@pytest.fixture
def client_cartella(cartella, tmp_path):
mod = _carica()
with TestClient(mod.create_app(cartella, tmp_path / "cfg")) as c:
yield c
# ---- anteprima
def test_anteprima_jpeg_ridotta_e_header(client):
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 500})
assert r.status_code == 200
assert r.headers["content-type"] == "image/jpeg"
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
assert img.shape[:2] == (250, 500) # lato lungo 2000 -> 500
assert r.headers["x-raddrizzato"] == "no"
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("2000", "1000") # frame pieno
assert r.headers["x-saturati-pct"] == "50.00"
assert r.headers["x-media"] == "177.5"
assert float(r.headers["x-nitidezza"]) > 0 # bordo netto fra le due metà
def test_anteprima_mai_ingrandita(client_cartella):
r = client_cartella.post("/camera/anteprima", json={"source": "camera:folder"})
assert r.status_code == 200
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
assert img.shape[:2] == (48, 64) # lato_max 1280 > 64: resta com'è
assert r.headers["x-nitidezza"] == "0.0" # grigio uniforme
assert r.headers["x-saturati-pct"] == "0.00"
assert r.headers["x-media"] == "128.0"
def test_anteprima_parametri_errati_400(client):
for corpo in ({}, {"source": SORGENTE, "lato_max": 100}, {"source": SORGENTE, "lato_max": 5000},
{"source": SORGENTE, "timeout_ms": 0}, {"source": SORGENTE, "grezzo": "si"}):
assert client.post("/camera/anteprima", json=corpo).status_code == 400, corpo
def test_anteprima_sorgente_inesistente_404(client_cartella):
assert client_cartella.post("/camera/anteprima",
json={"source": "camera:non-esiste"}).status_code == 404
def test_anteprima_errore_camera_503_e_timeout_504(client):
from visionsuite.camera.camera_interface import CameraGrabError
def rotta(timeout=0):
raise CameraGrabError("Camera is not open")
client.finta.grab = rotta
r = client.post("/camera/anteprima", json={"source": SORGENTE})
assert r.status_code == 503 and r.json()["detail"] == "La camera non è aperta"
def lenta(timeout=0):
raise TimeoutError("scaduto")
client.finta.grab = lenta
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 504
# ---- parametri
def test_parametri_regolabile(client):
r = client.get("/camera/parametri", params={"source": SORGENTE})
assert r.status_code == 200
j = r.json()
assert j["source"] == SORGENTE and j["regolabile"] is True
assert list(j["parametri"]) == ["esposizione", "esposizione_auto", "guadagno", "guadagno_auto"]
assert j["parametri"]["esposizione"]["valore"] == 5000.0
assert j["parametri"]["esposizione_auto"]["scelte"] == ["Off", "Once", "Continuous"]
def test_parametri_solo_quelli_supportati(cartella, tmp_path, monkeypatch):
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(supportati=["esposizione"])) as c:
j = c.get("/camera/parametri", params={"source": SORGENTE}).json()
assert j["regolabile"] is True and list(j["parametri"]) == ["esposizione"]
def test_parametri_sorgente_non_regolabile(client_cartella):
j = client_cartella.get("/camera/parametri", params={"source": "camera:folder"}).json()
assert j == {"source": "camera:folder", "regolabile": False, "parametri": {}}
def test_parametri_senza_source_400(client):
assert client.get("/camera/parametri").status_code == 400
def test_put_scrive_prima_gli_auto(client):
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {
"esposizione": 8000, "guadagno": 3.5, "esposizione_auto": "Off", "guadagno_auto": "Off"}})
assert r.status_code == 200
assert client.finta.scritture == [
("esposizione_auto", "Off", 3), ("guadagno_auto", "Off", 3),
("esposizione", 8000, 3), ("guadagno", 3.5, 3)]
j = r.json() # valori riletti dalla camera
assert j["parametri"]["esposizione"]["valore"] == 8000
assert j["parametri"]["esposizione_auto"]["valore"] == "Off"
def test_put_nome_ignoto_400(client):
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {"frame_rate": 10}})
assert r.status_code == 400
assert client.finta.scritture == []
for corpo in ({"source": SORGENTE}, {"source": SORGENTE, "valori": {}},
{"source": SORGENTE, "valori": {"guadagno": True}}):
assert client.put("/camera/parametri", json=corpo).status_code == 400, corpo
def test_put_non_regolabile_409(client_cartella):
r = client_cartella.put("/camera/parametri",
json={"source": "camera:folder", "valori": {"esposizione": 1000}})
assert r.status_code == 409
assert r.json()["detail"] == "questa sorgente non ha parametri regolabili"
def test_put_rifiuto_della_camera_422(cartella, tmp_path, monkeypatch):
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(rifiuta=["guadagno"])) as c:
r = c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"guadagno": 5}})
assert r.status_code == 422
assert "guadagno" in r.json()["detail"]
# ---- salvataggio e riapertura
def test_salva_crea_il_file(client):
r = client.post("/camera/parametri/salva", json={"source": SORGENTE})
assert r.status_code == 200
j = r.json()
assert j["source"] == SORGENTE and j["salvati"] is True
atteso = client.config / "settaggi" / j["file"]
assert atteso.is_file()
assert j["file"].startswith("camera_finta-") and j["file"].endswith(".json")
assert json.loads(atteso.read_text(encoding="utf-8"))["esposizione"] == 5000.0
def test_salva_non_regolabile_409(client_cartella):
r = client_cartella.post("/camera/parametri/salva", json={"source": "camera:folder"})
assert r.status_code == 409
def test_riapertura_carica_i_settaggi(cartella, tmp_path, monkeypatch):
config = tmp_path / "cfg"
prima = Finta()
with _client(cartella, config, monkeypatch, prima) as c:
c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"esposizione": 7777}})
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
seconda = Finta() # servizio riavviato: camera nuova, valori di fabbrica
with _client(cartella, config, monkeypatch, seconda) as c:
assert seconda.caricato is None # niente apertura finché nessuno la chiede
c.get("/camera/parametri", params={"source": SORGENTE})
assert seconda.caricato == config / "settaggi" / file
assert seconda.valori["esposizione"] == 7777
def test_settaggi_rotti_non_bloccano_l_apertura(cartella, tmp_path, monkeypatch):
class Rotta(Finta):
def carica_settaggi(self, percorso, forza=False):
raise ValueError("file illeggibile")
config = tmp_path / "cfg"
with _client(cartella, config, monkeypatch, Finta()) as c:
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
assert (config / "settaggi" / file).is_file()
with _client(cartella, config, monkeypatch, Rotta()) as c:
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
def test_apertura_chiede_il_frame_piu_recente(cartella, tmp_path, monkeypatch):
# #89: solo_ultimo_frame() una volta sola all'apertura, non a ogni richiesta
class ConUltimoFrame(Finta):
chiamate = 0
def solo_ultimo_frame(self, attivo=True):
self.chiamate += 1
finta = ConUltimoFrame()
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
for _ in range(3):
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
assert finta.chiamate == 1
def test_frame_piu_recente_non_impostabile_non_blocca_l_apertura(cartella, tmp_path, monkeypatch, caplog):
# #89: producer senza il nodo -> avviso nel log, apertura riuscita
class SenzaNodo(Finta):
def solo_ultimo_frame(self, attivo=True):
raise CameraStateError("questo producer non espone StreamBufferHandlingMode")
with caplog.at_level("WARNING"):
with _client(cartella, tmp_path / "cfg", monkeypatch, SenzaNodo()) as c:
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
assert "frame piu' recente non impostabile" in caplog.text
def test_sorgente_senza_solo_ultimo_frame_si_apre(cartella, tmp_path, monkeypatch):
# #89: FolderCamera e simili non hanno il metodo: nessun errore
assert not hasattr(Finta(), "solo_ultimo_frame")
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta()) as c:
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
# ---- pagina
def test_acquisizione_alias_della_pagina(client):
a = client.get("/acquisizione")
c = client.get("/calibrazione")
assert a.status_code == 200 and a.text == c.text
+186
View File
@@ -0,0 +1,186 @@
"""Test della specchiatura orizzontale/verticale per sorgente (#110): scripts/camera_service.py.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Copre: GET/PUT /camera/orientamento, effetto su /camera/grab e /camera/anteprima,
persistenza al riavvio, file rotto, validazione, scarto dei frame tenuti, avviso
sulla calibrazione densa. Sorgente: camera:folder su un'immagine 4x3 asimmetrica.
"""
import importlib.util
from pathlib import Path
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
from test_calibrazione import ( # calibrazione densa reale (#84), per l'avviso
SORGENTE as SORGENTE_DENSA, cartella_densa, carica_su, client_denso, salva_con_nome,
)
__all__ = ["cartella_densa", "client_denso"] # fixture importate, restano usate
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
SORGENTE = "camera:folder"
def _carica():
spec = importlib.util.spec_from_file_location("camera_service_orient", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def cartella(tmp_path):
# #110: 4x3 tutta nera con UN pixel acceso in alto a sinistra (riga 0, colonna 0)
img = tmp_path / "foto"
img.mkdir()
f = np.zeros((3, 4, 3), np.uint8)
f[0, 0] = 255
cv2.imwrite(str(img / "a.png"), f)
return img
@pytest.fixture
def config(tmp_path):
return tmp_path / "cfg"
@pytest.fixture
def client(cartella, config):
mod = _carica()
with TestClient(mod.create_app(cartella, config)) as c:
yield c
def _acceso(png: bytes):
"""(riga, colonna) del pixel acceso nel PNG; controlla che sia uno solo."""
f = cv2.imdecode(np.frombuffer(png, np.uint8), cv2.IMREAD_COLOR)
assert f.shape[:2] == (3, 4)
r, c = np.nonzero(f.max(axis=2) > 128)
assert len(r) == 1
return int(r[0]), int(c[0])
def _imposta(c, orizz, vert, source=SORGENTE):
return c.put("/camera/orientamento", json={"source": source, "orizzontale": orizz, "verticale": vert})
def _grab(c):
r = c.post("/camera/grab", json={"source": SORGENTE})
assert r.status_code == 200, r.text
return _acceso(r.content)
def test_get_senza_file_false_false(client):
r = client.get("/camera/orientamento", params={"source": SORGENTE})
assert r.status_code == 200
assert r.json() == {"source": SORGENTE, "orizzontale": False, "verticale": False}
def test_get_senza_source_400(client):
assert client.get("/camera/orientamento").status_code == 400
@pytest.mark.parametrize("orizz,vert,atteso", [
(False, False, (0, 0)), # originale: alto a sinistra
(True, False, (0, 3)), # orizzontale: alto a destra
(False, True, (2, 0)), # verticale: basso a sinistra
(True, True, (2, 3)), # entrambi: basso a destra (180 gradi)
])
def test_grab_specchiato(client, orizz, vert, atteso):
r = _imposta(client, orizz, vert)
assert r.status_code == 200, r.text
assert (r.json()["orizzontale"], r.json()["verticale"]) == (orizz, vert)
assert client.get("/camera/orientamento", params={"source": SORGENTE}).json()["orizzontale"] is orizz
assert _grab(client) == atteso
def test_tutti_false_toglie_la_sorgente_dal_file(client, config):
_imposta(client, True, False)
assert SORGENTE in (config / "orientamento.json").read_text(encoding="utf-8")
_imposta(client, False, False)
assert SORGENTE not in (config / "orientamento.json").read_text(encoding="utf-8")
assert _grab(client) == (0, 0)
def test_anteprima_specchiata_dimensioni_invariate(client):
_imposta(client, True, True)
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 4096})
assert r.status_code == 200, r.text
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("4", "3")
f = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
assert f.shape == (3, 4)
# JPEG con perdite: il pixel acceso e' il piu' chiaro, in basso a destra
assert np.unravel_index(int(np.argmax(f)), f.shape) == (2, 3)
def test_persistenza_al_riavvio(cartella, config):
mod = _carica()
with TestClient(mod.create_app(cartella, config)) as c1:
assert _imposta(c1, True, False).status_code == 200
with TestClient(_carica().create_app(cartella, config)) as c2:
j = c2.get("/camera/orientamento", params={"source": SORGENTE}).json()
assert (j["orizzontale"], j["verticale"]) == (True, False)
assert _grab(c2) == (0, 3)
def test_file_rotto_non_blocca_l_avvio(cartella, config):
config.mkdir(parents=True)
(config / "orientamento.json").write_text("{non e json", encoding="utf-8")
with TestClient(_carica().create_app(cartella, config)) as c:
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
assert (j["orizzontale"], j["verticale"]) == (False, False)
assert _imposta(c, False, True).status_code == 200 # e si puo' riscrivere
def test_put_campi_non_bool_400(client):
for corpo in ({"source": SORGENTE, "orizzontale": 1, "verticale": False},
{"source": SORGENTE, "orizzontale": True, "verticale": "si"},
{"source": SORGENTE, "orizzontale": True},
{"orizzontale": True, "verticale": True}):
assert client.put("/camera/orientamento", json=corpo).status_code == 400, corpo
def test_put_scarta_i_frame_tenuti(client):
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 200
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 200
_imposta(client, True, False)
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 404
# col frame tenuto scartato anche l'ultimo grezzo dell'anteprima e' sparito
assert client.post("/camera/anteprima/calibrata", json={"source": SORGENTE}).status_code == 404
def test_avviso_senza_mappa_e_null(client):
j = _imposta(client, True, False).json()
assert j["avviso"] is None
def test_avviso_con_mappa_attiva(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE_DENSA, "square_mm": "2"}).json()
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
assert carica_su(client_denso, SORGENTE_DENSA, "Densa").status_code == 200
r = _imposta(client_denso, True, False, source=SORGENTE_DENSA)
assert r.status_code == 200, r.text
assert "«Densa»" in r.json()["avviso"] and "va rifatta" in r.json()["avviso"]
# stesso valore di nuovo: nulla e' cambiato, nessun avviso
assert _imposta(client_denso, True, False, source=SORGENTE_DENSA).json()["avviso"] is None
def test_scrittura_fallita_500_e_orientamento_di_prima(cartella, config, monkeypatch):
# #110: se orientamento.json non si scrive, in memoria resta l'orientamento di prima
mod = _carica()
with TestClient(mod.create_app(cartella, config)) as c:
assert _imposta(c, True, False).status_code == 200 # stato di partenza: orizzontale
def rotto(*a, **k):
raise OSError("disco pieno")
monkeypatch.setattr(mod.os, "replace", rotto)
assert _imposta(c, False, True).status_code == 500
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
assert (j["orizzontale"], j["verticale"]) == (True, False)
assert _grab(c) == (0, 3) # ancora solo orizzontale
# sorgente che prima non c'era: la voce non resta
assert c.put("/camera/orientamento", json={"source": "altra", "orizzontale": True, "verticale": True}).status_code == 500
j = c.get("/camera/orientamento", params={"source": "altra"}).json()
assert (j["orizzontale"], j["verticale"]) == (False, False)
+116
View File
@@ -0,0 +1,116 @@
"""Test di scripts/camera_service.py (#72).
Uso: vendor/visionsuite/.venv/bin/python -m pytest scripts/tests/test_camera_service.py -q
Dipende da: venv di VisionSuite (vs-camera, fastapi, httpx, opencv, pytest).
"""
import importlib.util
from pathlib import Path
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
def _carica():
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def cartella(tmp_path):
for nome, (w, h) in {"a.png": (64, 48), "b.png": (64, 48)}.items():
cv2.imwrite(str(tmp_path / nome), np.full((h, w, 3), 128, np.uint8))
return tmp_path
@pytest.fixture
def client(cartella):
mod = _carica()
with TestClient(mod.create_app(cartella)) as c:
c.mod = mod
yield c
def test_sources_contiene_folder(client):
r = client.get("/camera/sources")
assert r.status_code == 200
nomi = [s["name"] for s in r.json()["sources"]]
assert "camera:folder" in nomi
voce = next(s for s in r.json()["sources"] if s["name"] == "camera:folder")
assert {"name", "kind", "label"} <= set(voce)
def test_grab_folder_restituisce_png(client):
r = client.post("/camera/grab", json={"source": "camera:folder"})
assert r.status_code == 200
assert r.headers["content-type"] == "image/png"
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
assert img.shape[:2] == (48, 64)
def test_grab_ripetuto_in_loop(client):
# due immagini in cartella, tre grab: il loop non deve esaurirsi
for _ in range(3):
assert client.post("/camera/grab", json={"source": "camera:folder"}).status_code == 200
def test_source_mancante_400(client):
assert client.post("/camera/grab", json={}).status_code == 400
assert client.post("/camera/grab", json={"source": " "}).status_code == 400
def test_source_inesistente_404(client):
assert client.post("/camera/grab", json={"source": "camera:non-esiste"}).status_code == 404
def test_timeout_non_valido_400(client):
r = client.post("/camera/grab", json={"source": "camera:folder", "timeout_ms": 0})
assert r.status_code == 400
def test_errore_camera_503_tradotto(client, monkeypatch):
from visionsuite.camera.camera_interface import CameraGrabError
class Rotta:
def grab(self, timeout):
raise CameraGrabError("Camera is not open")
def close(self):
pass
monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Rotta())
r = client.post("/camera/grab", json={"source": "camera:finta"})
assert r.status_code == 503
assert r.json()["detail"] == "La camera non è aperta"
def test_timeout_504(client, monkeypatch):
class Lenta:
def grab(self, timeout):
raise TimeoutError("scaduto")
def close(self):
pass
monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Lenta())
r = client.post("/camera/grab", json={"source": "camera:lenta"})
assert r.status_code == 504
def test_sorgente_aperta_una_volta(client, monkeypatch):
aperture = []
reale = client.mod.open_source
def conta(*a, **k):
aperture.append(a[0])
return reale(*a, **k)
monkeypatch.setattr(client.mod, "open_source", conta)
for _ in range(3):
client.post("/camera/grab", json={"source": "camera:folder"})
assert aperture == ["camera:folder"]
+292
View File
@@ -0,0 +1,292 @@
"""Test di #92: immagini caricate come sorgenti e anteprima calibrata (scripts/camera_service.py).
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Solo tmp_path: niente ProgramData, niente camera vera. Le immagini le scrive il test.
"""
from urllib.parse import quote, unquote
import cv2
import numpy as np
import pytest
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
CAMPI, _png, carica_su, cartella, client, salva_con_nome, una_scacchiera,
una_scacchiera_densa,
)
__all__ = ["cartella", "client"] # le fixture importate restano usate
DENSA = "densa.png"
PIANA = "piana.png"
def _modulo(c) -> dict:
"""Il dict globale di camera_service (per sostituire open_source con una spia)."""
return next(r.endpoint for r in c.app.routes if r.path == "/camera/grab").__globals__
def _carica(c, nome: str, img: np.ndarray, **extra):
"""POST /camera/immagini con un'immagine codificata PNG (o il contenuto dato)."""
return c.post("/camera/immagini", files={"image": (nome, _png(img), "image/png")}, **extra)
def _densa(**kw) -> np.ndarray:
return una_scacchiera_densa(k=1e-7, **kw)
def _calibra_e_attiva(c, nome_immagine: str, nome_calibrazione: str, **campi):
"""Calcola dalla sorgente, salva con nome e la rende attiva su quella sorgente."""
r = c.post("/camera/calibrate", data={"source": nome_immagine, "square_mm": "2", **campi})
assert r.status_code == 200, r.text
assert salva_con_nome(c, r.json(), nome_calibrazione).status_code == 201
assert carica_su(c, nome_immagine, nome_calibrazione).status_code == 200
def _anteprima(c, nome: str, **extra):
return c.post("/camera/anteprima", json={"source": nome, "grezzo": True, **extra})
def _calibrata(c, nome: str, **extra):
return c.post("/camera/anteprima/calibrata", json={"source": nome, **extra})
# ---- POST /camera/immagini
def test_carica_201_salva_in_cartella_immagini(client):
r = _carica(client, "calibration_135_1.bmp", una_scacchiera())
assert r.status_code == 201, r.text
assert r.json() == {"source": "calibration_135_1.bmp",
"label": "immagine — calibration_135_1.bmp", "kind": "image"}
assert (client.config / "immagini" / "calibration_135_1.bmp").is_file()
def test_carica_ripulisce_il_nome_e_toglie_il_percorso(client):
r = _carica(client, "C:\\foto\\ban co è1.PNG", una_scacchiera())
assert r.status_code == 201, r.text
assert r.json()["source"] == "ban_co_1.PNG" # niente percorso, caratteri sicuri, estensione intatta
assert (client.config / "immagini" / "ban_co_1.PNG").is_file()
def test_carica_stesso_nome_sostituisce(client):
_carica(client, "a.png", np.full((48, 64), 10, np.uint8))
assert _carica(client, "a.png", np.full((30, 40), 200, np.uint8)).status_code == 201
assert [f.name for f in (client.config / "immagini").iterdir()] == ["a.png"]
img = cv2.imread(str(client.config / "immagini" / "a.png"), cv2.IMREAD_UNCHANGED)
assert img.shape[:2] == (30, 40)
def test_carica_senza_file_400(client):
assert client.post("/camera/immagini").status_code == 400
def test_carica_estensione_non_ammessa_400(client):
r = client.post("/camera/immagini", files={"image": ("note.txt", b"ciao", "text/plain")})
assert r.status_code == 400
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
def test_carica_file_illeggibile_422_e_niente_su_disco(client):
r = client.post("/camera/immagini", files={"image": ("rotta.png", b"non e una immagine", "image/png")})
assert r.status_code == 422
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
# ---- GET /camera/sources
def test_sources_con_immagini_e_senza_camera_file(client):
_carica(client, "b.png", una_scacchiera())
_carica(client, "a.png", una_scacchiera())
voci = client.get("/camera/sources").json()["sources"]
nomi = [v["name"] for v in voci]
assert "camera:file" not in nomi
assert "camera:folder" in nomi
assert nomi[-2:] == ["a.png", "b.png"] # le immagini stanno in coda
assert voci[-1] == {"name": "b.png", "kind": "image", "label": "immagine — b.png"}
assert nomi.index("camera:folder") < nomi.index("a.png")
def test_sources_camere_in_cache_e_aggiorna(client):
# #92: la ricerca camere (~10 s) si fa una volta e resta 60 s; aggiorna=true la rilancia; le immagini sono sempre fresche
g = _modulo(client)
originale = g["list_sources"]
chiamate = []
def finto(cartella, cameras=True, **kw):
if cameras:
chiamate.append(1)
return originale(cartella, cameras=cameras, **kw)
g["list_sources"] = finto
try:
client.get("/camera/sources") # se la ricerca d'avvio e' ancora in corso aspetta il suo lock
base = len(chiamate)
_carica(client, "nuova.png", una_scacchiera())
voci = client.get("/camera/sources").json()["sources"]
client.get("/camera/sources")
assert len(chiamate) == base # seconda e terza chiamata: nessuna nuova ricerca
assert voci[-1]["name"] == "nuova.png" # ma l'immagine appena caricata c'e'
client.get("/camera/sources", params={"aggiorna": "true"})
assert len(chiamate) == base + 1 # aggiorna=true la rilancia
client.get("/camera/sources")
assert len(chiamate) == base + 1 # e il risultato nuovo torna in cache
finally:
g["list_sources"] = originale
def test_sources_senza_immagini_non_crea_la_cartella(client):
voci = client.get("/camera/sources").json()["sources"]
assert all(v["kind"] != "image" for v in voci)
# ---- grab / anteprima / calibrate su un'immagine caricata
def test_grab_anteprima_e_grab_ripetuti_su_immagine(client):
_carica(client, "foto.png", una_scacchiera())
for _ in range(3): # l'anteprima continua non deve esaurire un'immagine sola
r = client.post("/camera/grab", json={"source": "foto.png"})
assert r.status_code == 200 and r.headers["content-type"] == "image/png"
for _ in range(3):
r = _anteprima(client, "foto.png")
assert r.status_code == 200 and r.headers["content-type"] == "image/jpeg"
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("520", "400")
def test_grab_immagine_inesistente_404(client):
assert client.post("/camera/grab", json={"source": "non_c_e.png"}).status_code == 404
def test_calibrate_su_immagine_origine_e_il_nome(client):
_carica(client, "calibration_135_1.bmp", una_scacchiera())
r = client.post("/camera/calibrate", data={"source": "calibration_135_1.bmp", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["origine"] == "calibration_135_1.bmp"
# ---- DELETE /camera/immagini
def test_elimina_204_poi_404_e_sparisce_dalle_sorgenti(client):
_carica(client, "foto.png", una_scacchiera())
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
assert not (client.config / "immagini" / "foto.png").exists()
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 404
assert "foto.png" not in [v["name"] for v in client.get("/camera/sources").json()["sources"]]
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 404
def test_elimina_nome_con_percorso_404(client):
_carica(client, "foto.png", una_scacchiera())
assert client.delete("/camera/immagini", params={"nome": "../immagini/foto.png"}).status_code == 404
assert client.delete("/camera/immagini").status_code == 404
assert (client.config / "immagini" / "foto.png").is_file()
def test_elimina_chiude_la_sorgente_aperta(client, monkeypatch):
chiuse = []
modulo = _modulo(client)
reale = modulo["open_source"]
def spia(*a, **k):
s = reale(*a, **k)
chiusura = s.close
s.close = lambda: (chiuse.append(a[0]), chiusura())[1]
return s
monkeypatch.setitem(modulo, "open_source", spia)
_carica(client, "foto.png", una_scacchiera())
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 200
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
assert chiuse == ["foto.png"]
# ---- POST /camera/anteprima/calibrata
def test_calibrata_senza_frame_404(client):
_carica(client, DENSA, _densa())
r = _calibrata(client, DENSA)
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
def test_calibrata_senza_attiva_409(client):
_carica(client, DENSA, _densa())
assert _anteprima(client, DENSA).status_code == 200
r = _calibrata(client, DENSA)
assert r.status_code == 409 and r.json()["detail"] == "nessuna calibrazione attiva su questa sorgente"
def test_calibrata_validazione_ingresso(client):
assert client.post("/camera/anteprima/calibrata", json={}).status_code == 400
assert _calibrata(client, DENSA, lato_max=100).status_code == 400
assert _calibrata(client, DENSA, lato_max=True).status_code == 400
def test_calibrata_200_densa_senza_nuova_lettura(client, monkeypatch):
modulo = _modulo(client)
reale = modulo["open_source"]
letture = []
def conta(*a, **k):
s = reale(*a, **k)
originale = s.grab
s.grab = lambda t=1000: (letture.append(1), originale(t))[1]
return s
monkeypatch.setitem(modulo, "open_source", conta)
_carica(client, DENSA, _densa())
_calibra_e_attiva(client, DENSA, "Banco àè 1")
assert _anteprima(client, DENSA).status_code == 200
prima = len(letture)
for _ in range(3):
r = _calibrata(client, DENSA)
assert r.status_code == 200, r.text
assert r.headers["content-type"] == "image/jpeg"
assert r.headers["X-Raddrizzato"] == "si"
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480")
assert unquote(r.headers["X-Calibrazione"]) == "Banco àè 1"
assert r.headers["X-Calibrazione"] == quote("Banco àè 1", safe="") # percent-encoded UTF-8
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
assert img.shape[:2] == (480, 640)
assert len(letture) == prima # la calibrata non legge mai un frame nuovo
def test_calibrata_ridotta_a_lato_max(client):
_carica(client, DENSA, _densa())
_calibra_e_attiva(client, DENSA, "Banco")
assert _anteprima(client, DENSA).status_code == 200
r = _calibrata(client, DENSA, lato_max=320)
assert r.status_code == 200
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
assert max(img.shape[:2]) == 320
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480") # misure del frame pieno
def test_anteprima_grezza_e_calibrata_usano_lo_stesso_frame_grezzo(client):
# anche con anteprima NON grezza la copia tenuta e' quella prima di raddrizzare
_carica(client, DENSA, _densa())
_calibra_e_attiva(client, DENSA, "Banco")
r = client.post("/camera/anteprima", json={"source": DENSA}) # calibrata: raddrizzata
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si"
r = _calibrata(client, DENSA)
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si" # raddrizzata una volta sola
def test_calibrata_risoluzione_diversa_409(client):
_carica(client, DENSA, _densa())
_calibra_e_attiva(client, DENSA, "Banco")
# stessa sorgente, immagine sostituita con un'altra risoluzione: la calibrazione resta attiva
assert _carica(client, DENSA, _densa(w=320, h=240)).status_code == 201
assert _calibrata(client, DENSA).status_code == 404 # sostituire l'immagine scorda il frame vecchio
assert _anteprima(client, DENSA).status_code == 200
r = _calibrata(client, DENSA)
assert r.status_code == 409
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
def test_calibrata_planare_senza_mappa_200_grezza(client):
# #95: senza mappa (planare) non e' piu' 409: esce il frame grezzo, X-Raddrizzato: no
_carica(client, PIANA, una_scacchiera())
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
assert _anteprima(client, PIANA).status_code == 200
r = _calibrata(client, PIANA)
assert r.status_code == 200
assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
+87
View File
@@ -0,0 +1,87 @@
"""Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio."""
import importlib.util
import re
from pathlib import Path
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
_spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
PAGINA = _mod.PAGINA
def test_titolo_e_stile_scuro():
assert "<title>Acquisizione camera</title>" in PAGINA
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
def test_rotte_usate():
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
assert '"' + rotta in PAGINA, rotta
def test_offline_e_senza_finestre_del_browser():
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
assert vietato not in PAGINA, vietato
def test_id_principali():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
assert attesi <= ids, attesi - ids
# ogni id usato dal JS con $("...") esiste nel markup
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids
def test_sezioni_e_testi_italiani():
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
"Attiva calibrazione"): # #92 #93
assert testo in PAGINA, testo
assert "it-IT" in PAGINA # numeri con la virgola
assert "Choose File" not in PAGINA
def test_localstorage_protetto():
# ogni uso di localStorage sta dentro un try
for m in re.finditer(r"localStorage[.]", PAGINA):
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
def test_specchiatura_per_sorgente(): # #110
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"specchiaO", "specchiaV", "msgSpecchia"} <= ids
assert '"/camera/orientamento' in PAGINA
assert "Specchia orizzontale" in PAGINA and "Specchia verticale" in PAGINA
def test_zoom_visori(): # #111, #122: i visori sono <tmf-viewer>, la barra e lo zoom vengono dal componente
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
for i in ("immagine", "immagineCal", "immagineCalTab", "overlay"): # grezza, calibrata, tab Calibrazione, angoli
assert re.search(r'<tmf-viewer id="%s"' % i, PAGINA), i
assert PAGINA.count("<tmf-viewer") == 4
for nome in ("grezza", "calibrata", "calibrazione", "angoli"):
assert 'save-name="%s"' % nome in PAGINA, nome
assert "zoomAdatta" not in PAGINA and "barraZoom" not in PAGINA and "creaVisore(" in PAGINA
assert not {"zoomMeno", "zoomPiu", "zoomPct"} & ids
assert "latoAnteprima()" in PAGINA and "unaAnteprima(1280)" not in PAGINA # lato_max segue lo zoom
assert '"viewer-zoom"' in PAGINA # lo zoom si legge dall'evento del componente
def test_script_del_visore_in_ordine(): # #122: i JS arrivano da /visore/ (offline), prima dello script di pagina
src = re.findall(r'<script src="([^"]+)"', PAGINA)
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
assert PAGINA.index("/visore/image_viewer.js") < PAGINA.index("<script>")
def test_nessuna_funzione_dichiarata_due_volte(): # #111: in JS vince l'ultima dichiarazione, in silenzio
script = re.search(r"<script>(.*?)</script>", PAGINA, re.S).group(1)
nomi = re.findall(r"function\s+([A-Za-z0-9_$]+)\s*\(", script)
doppi = sorted({n for n in nomi if nomi.count(n) > 1})
assert not doppi, "funzioni dichiarate piu' volte: " + ", ".join(doppi)
+143
View File
@@ -0,0 +1,143 @@
"""Test della pagina a due tab (#91, #92): contenuto statico di PAGINA, senza servizio."""
import importlib.util
import re
from pathlib import Path
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
_spec = importlib.util.spec_from_file_location("calibrazione_pagina_tab", _FILE)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
PAGINA = _mod.PAGINA
def test_due_tab_con_ruolo_e_selezione():
assert re.search(r'<button id="tabAcq"[^>]*role="tab"[^>]*aria-selected="true"', PAGINA)
assert re.search(r'<button id="tabCal"[^>]*role="tab"[^>]*aria-selected="false"', PAGINA)
assert ">Acquisizione</button>" in PAGINA and ">Calibrazione</button>" in PAGINA
assert 'role="tablist"' in PAGINA and 'role="tabpanel"' in PAGINA
assert 'aria-selected", cal ?' in PAGINA # la selezione cambia da JS
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
def test_sorgente_in_cima_poi_riquadri():
# #92: Sorgente, anteprima e parametri stanno nel pannello Acquisizione; Sorgente prima dei riquadri
i_acq = PAGINA.index('id="pannelloAcq"')
i_cal = PAGINA.index('id="pannelloCal"')
i_sorg = PAGINA.index('id="sezioneSorgente"')
i_ant = PAGINA.index('id="sezioneAnteprima"')
i_riq = PAGINA.index('id="riquadri"')
assert i_acq < i_sorg < i_ant < i_riq < PAGINA.index('id="sezioneParametri"') < i_cal
assert PAGINA.index('id="sezioneCalibrazione"') > i_cal
# barra: casella calibrata e mirino; la vecchia casella «Immagine grezza» non c'e' piu'
assert 'id="mostraCal"' in PAGINA and "Attiva calibrazione" in PAGINA
assert 'id="grezzo"' not in PAGINA and "Immagine grezza (senza correzione)" not in PAGINA
# riquadri: «Immagine» sempre visibile, «Calibrata» nascosta finche' la casella e' spenta
assert re.search(r'<div class="riquadro" id="riquadroCal" hidden>', PAGINA)
assert "grezzo: true" in PAGINA
def test_id_del_pannello_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"calSorgente", "quadrato", "acquisisci", "nomeCal", "salva", "msgSalva", "calibrazioni",
"msgElenco", "stato", "risultato", "overlay",
"caricaImmagine", "fileImmagine", "rimuoviImmagine", "riquadroCal", "immagineCal", "mirinoCal"}
assert attesi <= ids, attesi - ids
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio
def test_calibrazione_senza_file_ne_radio():
# #92: l'immagine da file e' una sorgente di acquisizione, non un'opzione della calibrazione
for sparito in ("origFile", "origSorgente", "scegliFile", "nomeFile", 'type="radio"', "Immagine da file", "Sorgente selezionata"):
assert sparito not in PAGINA, sparito
# l'unico input file e' nella sezione Sorgente, in cima alla tab Acquisizione
assert PAGINA.count('type="file"') == 1
assert PAGINA.index('type="file"') < PAGINA.index('id="pannelloCal"')
assert "immagine grezza dalla tab Acquisizione" in PAGINA
# il calcolo manda solo la sorgente (e il lato), mai il file
assert 'f.append("source", sorgente)' in PAGINA and 'f.append("image"' not in PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
def test_testi_del_pannello():
for testo in ("Camera o immagine", "Carica immagine da file...", "Rimuovi immagine", "Calcola",
"Calibrazioni salvate", "risoluzione diversa", "Calibrata",
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome",
"calibrata: ", "non calibrata"):
assert testo in PAGINA, testo
def test_rotte_del_contratto():
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate",
"/camera/calibration?source=", "/camera/immagini", "/camera/immagini?nome=",
"/camera/anteprima/calibrata"): # #92
assert '"' + rotta in PAGINA, rotta
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
assert parola in PAGINA, parola
# il calcolo non salva piu': il campo save sparisce
assert 'append("save"' not in PAGINA
def test_nessuna_finestra_del_browser_e_offline():
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
assert vietato not in PAGINA, vietato
def test_acquisizione_di_87_90_conservata():
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
for parola in ("Parametri camera", "Mirino centrale",
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
assert parola in PAGINA, parola
def test_pagina_stretta():
assert "@media (max-width: 500px)" in PAGINA
# #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale
assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA
assert "Lettura dei parametri..." in PAGINA
def test_ciclo_non_sospeso_nella_tab_calibrazione():
# #94: il ciclo non si ferma piu' entrando in Calibrazione (prima #92: A.riprendi); Calcola senza blocco aspetta le richieste in volo
assert "A.riprendi" not in PAGINA
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert "fermaAnteprima();" in calcola and "A.inVolo) await pausa(50)" in calcola
def test_calibrata_dopo_anteprima_e_dopo_carica():
# #92: la calibrata segue ogni anteprima grezza riuscita e si aggiorna dopo Carica/Scollega; 404/409 al posto dell'immagine
assert "await aggiornaCalibrata(lato);" in PAGINA
assert PAGINA.count("aggiornaCalibrata();") >= 3
assert "messaggioCalibrata(await dettaglio(r))" in PAGINA
assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA
def test_elenco_sorgenti_senza_attese_e_calibrata_solo_se_serve():
# #92: la voce dell'immagine caricata si aggiunge in pagina (niente rilettura dell'elenco); «Aggiorna» forza la ricerca camere
assert "aggiungiSorgente(j);" in PAGINA and "caricaSorgenti(j.source)" not in PAGINA
assert 'id="aggiornaSorgenti"' in PAGINA and ">Aggiorna</button>" in PAGINA
assert '"?aggiorna=true"' in PAGINA
# nessuna calibrazione attiva: il riquadro lo dice senza chiamare il servizio; dopo un 409 non si richiede piu'
assert "if (A.calAttiva === false)" in PAGINA
assert "nessuna calibrazione attiva su questa sorgente" in PAGINA
assert "if (r.status === 409) A.bloccoCal = true;" in PAGINA and "if (A.bloccoCal) return;" in PAGINA
assert PAGINA.count("A.bloccoCal = false;") >= 3 # cambio sorgente, Carica, Scollega
def test_attiva_calibrazione_con_scelta_e_conferma():
# #93: sotto la calibrata una combo e CONFERMA; la casella cambia nome
for i in ('id="sceltaCal"', 'id="confermaCal"', 'id="msgSceltaCal"'):
assert i in PAGINA, i
assert "Attiva calibrazione" in PAGINA and ">CONFERMA</button>" in PAGINA
assert "Mostra immagine calibrata" not in PAGINA
assert PAGINA.index('id="riquadroCal"') < PAGINA.index('id="sceltaCal"') < PAGINA.index('id="confermaCal"') < PAGINA.index('id="msgSceltaCal"')
# il gestore di CONFERMA usa PUT calibrazione-attiva (via caricaNome) o DELETE calibration (via scollega), messaggio sotto la calibrata
gestore = PAGINA[PAGINA.index('$("confermaCal").onclick'):]
gestore = gestore[:gestore.index("};")]
assert 'caricaNome(nome, "msgSceltaCal")' in gestore and 'scollega("msgSceltaCal")' in gestore
assert 'method: "PUT"' in PAGINA and "/camera/calibrazione-attiva" in PAGINA
assert 'method: "DELETE"' in PAGINA and '"/camera/calibration?source="' in PAGINA
# voce vuota «nessuna», suffisso di risoluzione, combo ridisegnata con l'elenco e spenta senza sorgente
assert "— nessuna (non calibrata) —" in PAGINA and "— risoluzione diversa" in PAGINA
assert "disegnaSceltaCal();" in PAGINA and "= !$(\"sorgente\").value" in PAGINA
+84
View File
@@ -0,0 +1,84 @@
"""Test delle rotte /visore/*.js di camera_service e della pagina che le usa (#122).
Uso: python -m pytest scripts/tests/test_visore_rotte.py -q
Dipende da: venv con fastapi, httpx, opencv (come test_camera_service.py).
image_viewer.js lo scrive un altro piano (V1): se manca nei sorgenti il test della rotta usa una cartella temporanea con file finti.
"""
import importlib.util
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
NOMI = ("image_viewer.js", "save_image.js", "vision_overlay.js")
def _carica():
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def client(tmp_path):
mod = _carica()
with TestClient(mod.create_app(tmp_path)) as c:
c.mod = mod
yield c
@pytest.fixture
def cartella_finta(client, tmp_path):
# file finti in una cartella temporanea: la rotta si prova senza dipendere da image_viewer.js del frontend
d = tmp_path / "js"
d.mkdir()
for n in NOMI:
(d / n).write_text("/* finto " + n + " */\n", encoding="utf-8")
client.mod._CARTELLE_VISORE[:] = [d]
return d
@pytest.mark.parametrize("nome", NOMI)
def test_rotta_serve_il_file(client, cartella_finta, nome):
r = client.get("/visore/" + nome)
assert r.status_code == 200
assert r.headers["content-type"].startswith("application/javascript")
assert r.content == (cartella_finta / nome).read_bytes()
@pytest.mark.parametrize("nome", ["altro.js", "camera_service.py", "..%2Fcamera_service.py", "image_viewer.JS", "image_viewer.js.map"])
def test_altri_nomi_danno_404(client, cartella_finta, nome):
assert client.get("/visore/" + nome).status_code == 404
def test_file_mancante_nella_cartella_da_404(client, tmp_path):
client.mod._CARTELLE_VISORE[:] = [tmp_path / "vuota"]
assert client.get("/visore/save_image.js").status_code == 404
def test_cartelle_di_default_sono_static_js_del_frontend(client):
# sviluppo: sorgenti del frontend; pacchetto Nuitka: static/ accanto al modulo (build.ps1 la include)
cartelle = _carica()._CARTELLE_VISORE
assert any(c.parts[-5:] == ("src", "frontend", "flask_app", "static", "js") for c in cartelle)
assert any(c.parts[-2:] == ("static", "js") and c.parent.parent == MODULO.parent for c in cartelle)
@pytest.mark.parametrize("nome", ("save_image.js", "vision_overlay.js", "image_viewer.js"))
def test_file_reali_del_frontend(client, nome):
reale = next((c / nome for c in client.mod._CARTELLE_VISORE if (c / nome).is_file()), None)
if reale is None:
pytest.skip(nome + " non e' ancora nei sorgenti del frontend (lo scrive il piano V1)")
r = client.get("/visore/" + nome)
assert r.status_code == 200 and r.content == reale.read_bytes()
@pytest.mark.parametrize("rotta", ("/acquisizione", "/calibrazione"))
def test_pagina_usa_quattro_visori_e_i_tre_script(client, rotta):
html = client.get(rotta).text
assert html.count("<tmf-viewer") == 4
src = re.findall(r'<script src="(/visore/[^"]+)"', html)
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
assert "zoomAdatta" not in html
+22 -4
View File
@@ -1,10 +1,18 @@
"""API Key authentication dependency for FastAPI.""" """API Key authentication dependency for FastAPI."""
from datetime import timedelta
from fastapi import Depends, HTTPException, Request, status 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 src.backend.database import get_db from src.backend.database import get_db
from src.backend.config import settings
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.services.auth_service import hash_api_key
from src.backend.utils.tempo import utcnow
_RENEW_INTERVAL = timedelta(minutes=5)
async def get_current_user( async def get_current_user(
@@ -24,15 +32,25 @@ async def get_current_user(
) )
result = await db.execute( result = await db.execute(
select(User).where(User.api_key == api_key, User.active == True) select(ApiKey, User)
.join(User, User.id == ApiKey.user_id)
.where(ApiKey.key_hash == hash_api_key(api_key), User.active == True)
) )
user = result.scalar_one_or_none() row = result.first()
now = utcnow()
if user is None: if row is None or row[0].expires_at <= now:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or inactive API key", detail="Invalid or expired API key",
) )
key_row, user = row
# Sliding TTL, written at most once per _RENEW_INTERVAL to spare the DB.
if key_row.last_used_at is None or now - key_row.last_used_at > _RENEW_INTERVAL:
key_row.last_used_at = now
key_row.expires_at = now + timedelta(hours=settings.api_key_ttl_hours)
await db.flush()
# Store user_id in request state for access logging middleware # Store user_id in request state for access logging middleware
request.state.user_id = user.id request.state.user_id = user.id
+8 -2
View File
@@ -6,9 +6,14 @@ 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 src.backend.api.middleware.rate_limit import RateLimitMiddleware
from src.backend.database import async_session_factory from src.backend.database import async_session_factory
from src.backend.models.orm.access_log import AccessLog from src.backend.models.orm.access_log import AccessLog
# AccessLog.action is String(100): a long path (with query-string-like
# segments) silently failed the insert before this truncation existed.
_ACTION_MAX_LENGTH = 100
class AccessLogMiddleware(BaseHTTPMiddleware): class AccessLogMiddleware(BaseHTTPMiddleware):
"""Middleware that logs every API request to the access_logs table.""" """Middleware that logs every API request to the access_logs table."""
@@ -30,11 +35,12 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
# Log asynchronously (don't block response) # Log asynchronously (don't block response)
try: try:
action = f"{request.method} {request.url.path}"[:_ACTION_MAX_LENGTH]
async with async_session_factory() as session: async with async_session_factory() as session:
await session.execute( await session.execute(
insert(AccessLog).values( insert(AccessLog).values(
user_id=user_id, user_id=user_id,
action=f"{request.method} {request.url.path}", action=action,
details={ details={
"method": request.method, "method": request.method,
"path": request.url.path, "path": request.url.path,
@@ -42,7 +48,7 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
"status_code": response.status_code, "status_code": response.status_code,
"duration_ms": round(duration_ms, 2), "duration_ms": round(duration_ms, 2),
}, },
ip_address=request.client.host if request.client else None, ip_address=RateLimitMiddleware._client_ip(request),
user_agent=request.headers.get("user-agent", "")[:500], user_agent=request.headers.get("user-agent", "")[:500],
) )
) )
+85 -19
View File
@@ -1,8 +1,10 @@
"""Rate limiting middleware for FastAPI. """Rate limiting middleware for FastAPI.
Implements in-memory sliding window rate limiting per client IP. Implements in-memory sliding window rate limiting per client IP.
Configurable limits for login and general endpoints. Configurable limits for login and endpoints that carry credentials, and for
everything else.
""" """
import re
import time import time
from collections import defaultdict from collections import defaultdict
from typing import Callable from typing import Callable
@@ -17,14 +19,34 @@ from src.backend.config import settings
class RateLimitMiddleware(BaseHTTPMiddleware): class RateLimitMiddleware(BaseHTTPMiddleware):
"""Middleware that enforces per-IP rate limits using a sliding window. """Middleware that enforces per-IP rate limits using a sliding window.
- Login endpoint (/api/auth/login): limited to `rate_limit_login` req/min. - Login bucket: `/api/auth/login`, the paths that carry a capoturno's
credentials (`/api/measurements/*/authorise`,
`/api/production-runs/{id}/pause|resume|close`), and everything under
`/api/setup/` (the setup password). Limited to `rate_limit_login`
req/min. Guessing a supervisor's or the setup password is the same
attack as guessing a login password, just aimed at a different endpoint.
- All other endpoints: limited to `rate_limit_general` req/min. - All other endpoints: limited to `rate_limit_general` req/min.
Returns HTTP 429 with Retry-After header when limit exceeded. Returns HTTP 429 with Retry-After header when limit exceeded.
Both configured limits are divided across worker processes before being
applied (see `_effective_limit`): each bucket lives in this process's
memory, so with `--workers N` a single configured limit is enforced N
times over across the cluster unless it is divided down first.
""" """
LOGIN_PATH = "/api/auth/login" LOGIN_PATH = "/api/auth/login"
# #59: il login con PIN e' lo stesso attacco (4 cifre: ancora piu' facile da indovinare)
LOGIN_PIN_PATH = "/api/auth/login-pin"
WINDOW_SECONDS = 60 WINDOW_SECONDS = 60
# Paths that carry a supervisor's (capoturno) credentials in the request
# body. Matched by pattern, not equality, because the id in the middle
# varies per request.
_SUPERVISOR_CREDENTIAL_PATTERNS = (
re.compile(r"^/api/measurements/\d+/authorise$"),
re.compile(r"^/api/production-runs/\d+/(?:pause|resume|close)$"),
)
def __init__(self, app) -> None: def __init__(self, app) -> None:
super().__init__(app) super().__init__(app)
# {ip: [timestamp, ...]} per bucket # {ip: [timestamp, ...]} per bucket
@@ -34,23 +56,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
@staticmethod @staticmethod
def _client_ip(request: Request) -> str: def _client_ip(request: Request) -> str:
"""Resolve the originating client IP, honoring proxy headers. """Resolve the originating client IP behind `trusted_proxy_count` proxies.
Order of precedence: ``X-Forwarded-For`` (first hop), ``X-Real-IP``, Nginx (and, behind it, the Flask client) each add one hop to
``request.client.host``. Required because Nginx and the Flask client X-Forwarded-For. The *leftmost* entries are whatever the original
sit between the tablet and the API; without parsing these headers caller claimed and are trivially spoofed; trust only extends to the
every tablet shares one bucket. hops actually appended by our own trusted proxies, counted from the
*right*. With `trusted_proxy_count = N`, the client IP is the Nth
element from the end of XFF (the one added by the outermost trusted
proxy). If XFF has fewer elements than that, or is absent, fall back
to `request.client.host` - except that `X-Real-IP` is tried first when
XFF is simply missing and at least one proxy is trusted.
With `trusted_proxy_count = 0`, no proxy header is honored at all
(neither XFF nor X-Real-IP) and only `request.client.host` is used.
""" """
xff = request.headers.get("x-forwarded-for") count = settings.trusted_proxy_count
if xff: if count > 0:
first = xff.split(",")[0].strip() xff = request.headers.get("x-forwarded-for")
if first: if xff:
return first hops = [h.strip() for h in xff.split(",") if h.strip()]
real = request.headers.get("x-real-ip") if len(hops) >= count:
if real: return hops[-count]
return real.strip() # Fewer hops than trusted proxies: the chain is short of what
# was configured, so fall back rather than trust an
# under-populated header.
else:
real = request.headers.get("x-real-ip")
if real:
return real.strip()
return request.client.host if request.client else "unknown" return request.client.host if request.client else "unknown"
@classmethod
def _is_login_bucket_request(cls, method: str, path: str) -> bool:
"""Whether this request counts against the login bucket rather than general.
`/api/setup/*` is checked regardless of method: `GET /api/setup/status`
now accepts an optional password (see setup.py), so guessing it there
is the same attack as guessing it on any of the POST endpoints.
"""
if path.startswith("/api/setup/"):
return True
if method != "POST":
return False
if path in (cls.LOGIN_PATH, cls.LOGIN_PIN_PATH):
return True
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
@staticmethod
def _effective_limit(configured: int) -> int:
"""Divide a configured per-minute limit across worker processes.
Rounds up and never returns less than 1, so a low configured limit
with high concurrency still allows at least one request per worker
per window rather than locking everyone out.
"""
concurrency = max(settings.web_concurrency, 1)
return max(1, -(-configured // concurrency))
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
@@ -97,10 +160,12 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self._evict_stale_ips(self._login_requests, now) self._evict_stale_ips(self._login_requests, now)
self._evict_stale_ips(self._general_requests, now) self._evict_stale_ips(self._general_requests, now)
# Check login-specific rate limit # Check login-bucket rate limit (login itself, supervisor-credential
if path == self.LOGIN_PATH and request.method == "POST": # paths, and the setup password)
if self._is_login_bucket_request(request.method, path):
allowed, retry_after = self._check_rate_limit( allowed, retry_after = self._check_rate_limit(
self._login_requests, client_ip, settings.rate_limit_login, now self._login_requests, client_ip,
self._effective_limit(settings.rate_limit_login), now,
) )
if not allowed: if not allowed:
return JSONResponse( return JSONResponse(
@@ -111,7 +176,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
# Check general rate limit # Check general rate limit
allowed, retry_after = self._check_rate_limit( allowed, retry_after = self._check_rate_limit(
self._general_requests, client_ip, settings.rate_limit_general, now self._general_requests, client_ip,
self._effective_limit(settings.rate_limit_general), now,
) )
if not allowed: if not allowed:
return JSONResponse( return JSONResponse(
@@ -10,16 +10,18 @@ from starlette.middleware.base import BaseHTTPMiddleware
from src.backend.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:"
) )
+46 -4
View File
@@ -1,5 +1,6 @@
"""Authentication router - login, logout, profile.""" """Authentication router - login, logout, profile."""
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db from src.backend.database import get_db
@@ -8,10 +9,17 @@ from src.backend.models.orm.user import User
from src.backend.models.api.user import ( from src.backend.models.api.user import (
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
PinLoginRequest,
PinUserItem,
UserProfileUpdate, UserProfileUpdate,
UserResponse, UserResponse,
) )
from src.backend.services.auth_service import authenticate_user, login_user, logout_user from src.backend.services.auth_service import (
authenticate_pin,
authenticate_user,
login_user,
logout_user,
)
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -32,13 +40,47 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
) )
@router.get("/pin-users", response_model=list[PinUserItem])
async def pin_users(db: AsyncSession = Depends(get_db)):
"""Utenti che possono entrare con PIN (#59): attivi, non admin, con PIN.
Pubblico per costruzione (serve prima del login): espone solo id e nome.
"""
result = await db.execute(
select(User).where(
User.active == True, # noqa: E712
User.is_admin == False, # noqa: E712
User.pin_hash.is_not(None),
)
)
items = [
PinUserItem(id=u.id, display_name=u.display_name or u.username)
for u in result.scalars().all()
]
items.sort(key=lambda i: i.display_name.casefold())
return items
@router.post("/login-pin", response_model=LoginResponse)
async def login_pin(data: PinLoginRequest, db: AsyncSession = Depends(get_db)):
"""Login con user_id e PIN a 4 cifre (#59); stessa risposta del login."""
user = await authenticate_pin(db, data.user_id, data.pin)
api_key = await login_user(db, user)
return LoginResponse(user=UserResponse.model_validate(user), api_key=api_key)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout( async def logout(
request: Request,
all_sessions: bool = False,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Logout - invalidate API key.""" """Logout. Deletes this session's API key only; ?all_sessions=true
await logout_user(db, user) deletes every key of the user."""
await logout_user(
db, user, key=request.headers.get("X-API-Key"), all_sessions=all_sessions
)
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
+148 -23
View File
@@ -6,9 +6,15 @@ from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status 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 sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings from src.backend.config import settings
from src.backend.database import get_db
from src.backend.api.middleware.api_key import get_current_user, require_maker from src.backend.api.middleware.api_key import get_current_user, require_maker
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,6 +26,93 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
ALLOWED_PDF_TYPE = "application/pdf" ALLOWED_PDF_TYPE = "application/pdf"
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE} ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
# Magic-byte signatures: the client-declared Content-Type is only what the
# browser claimed and cannot be trusted (#36 point 2 — a file named "x.html"
# declared image/png was stored and then served by extension as text/html,
# which a browser executes). SVG is intentionally not recognised: it is not
# in ALLOWED_TYPES and can itself carry script content.
_MAGIC_SIGNATURES: list[tuple[bytes, str, str]] = [
# (signature, mime type, canonical extension)
(b"\x89PNG\r\n\x1a\n", "image/png", ".png"),
(b"\xff\xd8\xff", "image/jpeg", ".jpg"),
(b"GIF87a", "image/gif", ".gif"),
(b"GIF89a", "image/gif", ".gif"),
(b"%PDF-", "application/pdf", ".pdf"),
]
# Read uploads in chunks so a declared-huge body is rejected as soon as it
# crosses the limit, instead of being buffered into memory in full first.
_UPLOAD_CHUNK_SIZE = 1024 * 1024
def detect_file_type(content: bytes) -> tuple[str, str] | None:
"""Identify a file's real type from its magic bytes.
Returns (mime_type, canonical_extension), or None when the content does
not match any type this system serves.
"""
for signature, mime, ext in _MAGIC_SIGNATURES:
if content.startswith(signature):
return mime, ext
# WebP: "RIFF" + 4-byte little-endian size + "WEBP"; the size varies.
if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
return "image/webp", ".webp"
return None
# Top-level folders nothing may DELETE through this router: statistics/reports are
# audit records, the logo is only ever replaced via POST /api/settings/logo.
# task_results/: allegati degli esiti dei task (#55), record di audit.
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/", "task_results/")
# GET under these needs a role that is meant to see production data, not just
# any authenticated user (a Maker or MeasurementTec has no business reading
# every production's statistics/report file by guessing its name).
_RESTRICTED_GET_PREFIXES = ("statistics/", "reports/")
def _can_read_restricted(user: User) -> bool:
return user.is_admin or user.has_role("Metrologist") or user.has_role("Supervisor")
async def _is_recipe_file_protected(db: AsyncSession, relative_path: str) -> bool:
"""True when `relative_path` (uploads-relative, forward slashes) must not be
deleted: it is a recipe's cover image, or a task/subtask file belonging to a
version that is either not the current one (kept for history) or already has
measurements against it (the audit trail needs it to make sense of them).
A path not referenced anywhere is safe to delete (orphaned upload).
"""
recipe_hit = (await db.execute(
select(Recipe.id).where(Recipe.image_path == relative_path)
)).scalar_one_or_none()
if recipe_hit is not None:
return True
version_id = (await db.execute(
select(RecipeTask.version_id).where(RecipeTask.file_path == relative_path)
)).scalar_one_or_none()
if version_id is None:
version_id = (await db.execute(
select(RecipeTask.version_id)
.join(RecipeSubtask, RecipeSubtask.task_id == RecipeTask.id)
.where(RecipeSubtask.image_path == relative_path)
)).scalar_one_or_none()
if version_id is None:
return False
version = (await db.execute(
select(RecipeVersion).where(RecipeVersion.id == version_id)
)).scalar_one_or_none()
if version is None or not version.is_current:
return True
has_measurements = (await db.execute(
select(Measurement.id).where(Measurement.version_id == version_id).limit(1)
)).scalar_one_or_none() is not None
return has_measurements
def validate_file_type(content_type: str) -> bool: def validate_file_type(content_type: str) -> bool:
"""Validate if file type is allowed.""" """Validate if file type is allowed."""
@@ -119,25 +212,36 @@ async def upload_file(
"""Upload an image or PDF file. """Upload an image or PDF file.
Files are stored in uploads/{recipe_id}/{version_id}/ Files are stored in uploads/{recipe_id}/{version_id}/
Thumbnails are generated for images. Thumbnails are generated for images. The type is authoritatively
determined from the file's magic bytes, not the client-declared
Content-Type (#36 point 2): the stored extension always matches what was
actually detected, so it can never be served back as something else.
""" """
# Validate file type # Read in chunks, stopping as soon as the size limit is crossed instead
if not validate_file_type(file.content_type): # of buffering an oversized body into memory first.
raise HTTPException( max_bytes = settings.max_upload_size_mb * 1024 * 1024
status_code=status.HTTP_400_BAD_REQUEST, buffer = bytearray()
detail=f"File type {file.content_type} not allowed. Must be image or PDF.", while True:
) chunk = await file.read(_UPLOAD_CHUNK_SIZE)
if not chunk:
# Read file content break
content = await file.read() buffer.extend(chunk)
if len(buffer) > max_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
)
content = bytes(buffer)
file_size = len(content) file_size = len(content)
# Validate file size # Validate file type from the actual bytes, not file.content_type
if not validate_file_size(file_size): detected = detect_file_type(content)
if detected is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB", detail="File type not allowed. Must be a recognised image or PDF.",
) )
detected_mime, detected_ext = detected
# Determine storage path # Determine storage path
if recipe_id is not None and version_id is not None: if recipe_id is not None and version_id is not None:
@@ -147,18 +251,17 @@ async def upload_file(
storage_dir.mkdir(parents=True, exist_ok=True) storage_dir.mkdir(parents=True, exist_ok=True)
# Sanitize the uploaded filename # Sanitize the uploaded filename, then force its extension to the one
# actually detected: a declared/renamed extension is not trusted either.
safe_filename = sanitize_filename(file.filename or "") safe_filename = sanitize_filename(file.filename or "")
base_name = Path(safe_filename).stem or "file"
safe_filename = f"{base_name}{detected_ext}"
# Generate unique filename if file already exists # Generate unique filename if file already exists
file_path = storage_dir / safe_filename file_path = storage_dir / safe_filename
counter = 1 counter = 1
while file_path.exists(): while file_path.exists():
name_parts = safe_filename.rsplit(".", 1) file_path = storage_dir / f"{base_name}_{counter}{detected_ext}"
if len(name_parts) == 2:
file_path = storage_dir / f"{name_parts[0]}_{counter}.{name_parts[1]}"
else:
file_path = storage_dir / f"{safe_filename}_{counter}"
counter += 1 counter += 1
# Write file # Write file
@@ -166,7 +269,7 @@ async def upload_file(
# Generate thumbnail for images # Generate thumbnail for images
thumbnail_path = None thumbnail_path = None
if file.content_type in ALLOWED_IMAGE_TYPES: if detected_mime in ALLOWED_IMAGE_TYPES:
thumbnail_dir = storage_dir / "thumbnails" thumbnail_dir = storage_dir / "thumbnails"
thumbnail_dir.mkdir(exist_ok=True) thumbnail_dir.mkdir(exist_ok=True)
thumbnail_path = thumbnail_dir / file_path.name thumbnail_path = thumbnail_dir / file_path.name
@@ -183,7 +286,7 @@ async def upload_file(
return { return {
"file_path": str(relative_path).replace("\\", "/"), "file_path": str(relative_path).replace("\\", "/"),
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None, "thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
"file_type": file.content_type, "file_type": detected_mime,
"file_size": file_size, "file_size": file_size,
} }
@@ -195,8 +298,14 @@ async def get_file(
): ):
"""Serve a file from the uploads directory. """Serve a file from the uploads directory.
Requires authentication but no specific role. Requires authentication; statistics/ and reports/ additionally require
Metrologist, Supervisor or admin, since those are production data.
""" """
if file_path.startswith(_RESTRICTED_GET_PREFIXES) and not _can_read_restricted(user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Metrologist, Supervisor or admin role required",
)
full_path = resolve_upload_path(file_path) full_path = resolve_upload_path(file_path)
return FileResponse(full_path) return FileResponse(full_path)
@@ -205,13 +314,29 @@ async def get_file(
async def delete_file( async def delete_file(
file_path: str, file_path: str,
user: User = Depends(require_maker), user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
): ):
"""Delete a file from the uploads directory. """Delete a file from the uploads directory.
Also deletes associated thumbnail if it exists. Also deletes associated thumbnail if it exists. statistics/, reports/ and
logos/ can never be deleted here (403); a recipe file still referenced by
its current version or by a version with measurements is kept (409).
""" """
if file_path.startswith(_UNDELETABLE_PREFIXES):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This folder cannot be deleted through the files API.",
)
full_path = resolve_upload_path(file_path) full_path = resolve_upload_path(file_path)
relative_path = str(full_path.relative_to(settings.upload_path)).replace("\\", "/")
if await _is_recipe_file_protected(db, relative_path):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="File is referenced by a current or measured recipe version.",
)
# Delete thumbnail if it exists # Delete thumbnail if it exists
if full_path.parent.name != "thumbnails": if full_path.parent.name != "thumbnails":
thumbnail_path = full_path.parent / "thumbnails" / full_path.name thumbnail_path = full_path.parent / "thumbnails" / full_path.name
+124 -13
View File
@@ -2,6 +2,8 @@
import csv import csv
import io import io
from datetime import datetime from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from src.backend.utils.tempo import as_utc_naive, iso_utc, to_local, utcnow
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -19,12 +21,17 @@ from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.setting import SystemSetting from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.models.api.measurement import ( from src.backend.models.api.measurement import (
CurrentCycleResponse,
MeasurementAuthorisation,
MeasurementBatchCreate, MeasurementBatchCreate,
MeasurementCreate, MeasurementCreate,
MeasurementListResponse, MeasurementListResponse,
MeasurementResponse, MeasurementResponse,
TaskProgressListResponse,
) )
from src.backend.services import auth_service, measurement_service
from src.backend.services.measurement_service import save_measurement from src.backend.services.measurement_service import save_measurement
from src.backend.services.production_export_service import neutralise_csv_cell
router = APIRouter(prefix="/api/measurements", tags=["measurements"]) router = APIRouter(prefix="/api/measurements", tags=["measurements"])
@@ -47,6 +54,7 @@ async def create_measurement(
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, 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:
@@ -56,6 +64,97 @@ async def create_measurement(
) )
@router.get("/pending-authorisation", response_model=MeasurementResponse | None)
async def get_pending_authorisation(
version_id: int = Query(..., gt=0),
production_run_id: int | None = Query(None, gt=0),
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""The out-of-tolerance measurement holding this operator up, or null.
Asked on load, so that reloading the page is not a way past the gate: the
screen finds the same block waiting for it that the server enforces.
"""
blocking = await measurement_service.pending_authorisation(
db, version_id, user.id, production_run_id,
)
return MeasurementResponse.model_validate(blocking) if blocking else None
@router.get("/task-progress", response_model=TaskProgressListResponse)
async def get_task_progress(
version_id: int = Query(..., gt=0),
production_run_id: int | None = Query(None, gt=0),
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""How many quotes of each measurement task are already taken.
The task list is where an operator picks up work they left behind, and until
now it showed a task begun and a task never touched identically.
"""
tasks = await measurement_service.task_progress(
db, version_id, user.id, production_run_id,
)
return TaskProgressListResponse(tasks=tasks)
@router.get("/current-cycle", response_model=CurrentCycleResponse)
async def get_current_cycle(
production_run_id: int = Query(..., gt=0),
task_id: int = Query(..., gt=0),
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Quotes of this task already measured in the cycle under way.
Asked on load, so that a reload mid-cycle does not present quotes again that
were taken a moment ago.
"""
rows = await measurement_service.current_cycle_measurements(
db, production_run_id, task_id,
)
return CurrentCycleResponse(measurements=rows)
@router.post("/{measurement_id}/authorise", response_model=MeasurementResponse)
async def authorise_measurement(
measurement_id: int,
action: MeasurementAuthorisation,
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Let an out-of-tolerance value stand, on the capoturno's name.
Credentials go to this endpoint rather than to a separate check because the
approval has to end up attached to the measurement. A check whose answer is
thrown away is what this point had before: a modal that closed.
"""
result = await db.execute(
select(Measurement).where(Measurement.id == measurement_id)
)
measurement = result.scalar_one_or_none()
if measurement is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Measurement not found",
)
if measurement.pass_fail != "fail":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This measurement is within tolerance: there is nothing to authorise",
)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
measurement = await measurement_service.authorise_measurement(
db, measurement, supervisor,
)
return MeasurementResponse.model_validate(measurement)
@router.post("/batch", response_model=list[MeasurementResponse]) @router.post("/batch", response_model=list[MeasurementResponse])
async def create_measurement_batch( async def create_measurement_batch(
data: MeasurementBatchCreate, data: MeasurementBatchCreate,
@@ -76,6 +175,7 @@ async def create_measurement_batch(
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, 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]
@@ -96,6 +196,7 @@ def _build_measurement_filters(
date_from: datetime | None, date_from: datetime | None,
date_to: datetime | None, date_to: datetime | None,
pass_fail: str | None, pass_fail: str | None,
production_run_id: int | None = None,
) -> list: ) -> list:
"""Build SQLAlchemy filter conditions shared by list and CSV export.""" """Build SQLAlchemy filter conditions shared by list and CSV export."""
filters = [] filters = []
@@ -121,6 +222,8 @@ def _build_measurement_filters(
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)
if production_run_id is not None:
filters.append(Measurement.production_run_id == production_run_id)
return filters return filters
@@ -132,9 +235,10 @@ async def get_measurements(
measured_by: int | None = Query(None), measured_by: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
production_run_id: int | None = Query(None, gt=0),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=500), per_page: int = Query(50, ge=1, le=500),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
@@ -150,7 +254,7 @@ async def get_measurements(
""" """
filters = _build_measurement_filters( filters = _build_measurement_filters(
recipe_id, version_id, subtask_id, measured_by, lot_number, recipe_id, version_id, subtask_id, measured_by, lot_number,
serial_number, date_from, date_to, pass_fail, serial_number, date_from, date_to, pass_fail, production_run_id,
) )
# Build query # Build query
@@ -189,15 +293,18 @@ async def export_measurements_csv(
measured_by: int | None = Query(None), measured_by: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Export measurements to CSV with configurable delimiter and decimal separator.""" """Export measurements to CSV with configurable delimiter and decimal separator."""
# Check user has MeasurementTec or Metrologist role # Check user has MeasurementTec or Metrologist role, same as require_role: an
if not (user.has_role("MeasurementTec") or user.has_role("Metrologist")): # admin with no explicit role assigned is still let through (#36 point 3).
if not (
user.has_role("MeasurementTec") or user.has_role("Metrologist") or user.is_admin
):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="MeasurementTec or Metrologist role required", detail="MeasurementTec or Metrologist role required",
@@ -250,9 +357,13 @@ async def export_measurements_csv(
# Rows # Rows
for m in measurements: for m in measurements:
# Format decimals with configured separator # Format decimals with configured separator
value_str = str(m.value).replace(".", decimal_separator) # #118: quota non misurata dalla visione -> NaN
value_str = "NaN" if m.value is None else str(m.value).replace(".", decimal_separator)
# #124: quota NaN (value None) -> deviazione NaN, non vuota
deviation_str = ( deviation_str = (
str(m.deviation).replace(".", decimal_separator) "NaN"
if m.value is None
else str(m.deviation).replace(".", decimal_separator)
if m.deviation is not None if m.deviation is not None
else "" else ""
) )
@@ -265,10 +376,10 @@ async def export_measurements_csv(
value_str, value_str,
m.pass_fail, m.pass_fail,
deviation_str, deviation_str,
m.lot_number or "", neutralise_csv_cell(m.lot_number or ""),
m.serial_number or "", neutralise_csv_cell(m.serial_number or ""),
m.input_method, m.input_method,
m.measured_at.isoformat(), iso_utc(m.measured_at),
m.synced_to_csv, m.synced_to_csv,
]) ])
@@ -278,6 +389,6 @@ async def export_measurements_csv(
iter([output.getvalue()]), iter([output.getvalue()]),
media_type="text/csv", media_type="text/csv",
headers={ headers={
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" "Content-Disposition": f"attachment; filename=measurements_{to_local(utcnow()).strftime('%Y%m%d_%H%M%S')}.csv"
}, },
) )
+206
View File
@@ -0,0 +1,206 @@
"""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, HTTPException, 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.models.api.monitor import MonitorRun
from src.backend.services import auth_service, monitor_service, production_service
router = APIRouter(prefix="/api/production-runs", tags=["production"])
_DERIVED = {
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
"close_task_ids",
}
async def _require_run_operator(user: User = Depends(get_current_user)) -> User:
"""Open/advance/pause/resume/close a run: MeasurementTec, Supervisor, or admin.
A Maker with no floor role could otherwise start and drive a production the
same way an operator does (gitea#24) - reading a run stays open to any
authenticated user, only the lifecycle actions are gated.
"""
if user.is_admin or user.has_role("MeasurementTec") or user.has_role("Supervisor"):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="MeasurementTec or Supervisor role required",
)
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,
),
# #53: task da compilare alla fine produzione
close_task_ids=await production_service.close_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(_require_run_operator),
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
async def _require_high_authority(user: User = Depends(get_current_user)) -> User:
"""Monitor of all stations (#57): admin or Supervisor (capoturno) only."""
if user.is_admin or user.has_role("Supervisor"):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Supervisor role required",
)
@router.get("/open", response_model=list[MonitorRun])
async def list_open_production_runs(
user: User = Depends(_require_high_authority),
db: AsyncSession = Depends(get_db),
):
"""Every open run with timer, last event and recent measurements (#57).
Declared before /{run_id} so "open" is not parsed as a run id.
"""
return await monitor_service.list_open_runs(db)
@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(_require_run_operator),
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(_require_run_operator),
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(_require_run_operator),
db: AsyncSession = Depends(get_db),
):
"""Fermo linea - requires a supervisor."""
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
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(_require_run_operator),
db: AsyncSession = Depends(get_db),
):
"""Restart a stopped line - requires a supervisor."""
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
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(_require_run_operator),
db: AsyncSession = Depends(get_db),
):
"""Fine produzione - requires a supervisor. Stops the timer for good."""
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
+18
View File
@@ -9,6 +9,7 @@ from src.backend.api.middleware.api_key import get_current_user, require_maker,
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.models.api.recipe import ( from src.backend.models.api.recipe import (
RecipeCreate, RecipeCreate,
RecipeDuplicate,
RecipeListResponse, RecipeListResponse,
RecipeResponse, RecipeResponse,
RecipeUpdate, RecipeUpdate,
@@ -52,6 +53,18 @@ async def create_recipe(
return _recipe_to_response(recipe) return _recipe_to_response(recipe)
@router.post("/{recipe_id}/duplicate", response_model=RecipeResponse, status_code=201)
async def duplicate_recipe(
recipe_id: int,
data: RecipeDuplicate,
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Duplicate a recipe with its current tasks under a new code (#52). Requires Maker role."""
recipe = await recipe_service.duplicate_recipe(db, recipe_id, data.code, data.name, user)
return _recipe_to_response(recipe)
@router.get("", response_model=RecipeListResponse) @router.get("", response_model=RecipeListResponse)
async def list_recipes( async def list_recipes(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@@ -189,6 +202,11 @@ async def parse_technical_sheet(
tasks = await ai_service.parse_technical_sheet(pdf_bytes) tasks = await ai_service.parse_technical_sheet(pdf_bytes)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except ai_service.AIResponseError as e:
# The AI model's own response was malformed (bad JSON, missing
# fields): that is the upstream service's fault, not ours (400).
logging.getLogger(__name__).error("AI response malformed: %s", e)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Risposta del servizio AI non valida")
except Exception as e: except Exception as e:
logging.getLogger(__name__).error("AI parsing error: %s", e) logging.getLogger(__name__).error("AI parsing error: %s", e)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
+9 -5
View File
@@ -1,5 +1,5 @@
"""Reports router — PDF report generation endpoints for Metrologist.""" """Reports router — PDF report generation endpoints for Metrologist."""
from datetime import datetime from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response from fastapi.responses import Response
@@ -18,11 +18,12 @@ async def download_spc_report(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Response: ) -> Response:
@@ -32,6 +33,7 @@ async def download_spc_report(
db, recipe_id, subtask_id, db, recipe_id, subtask_id,
version_id, date_from, date_to, version_id, date_from, date_to,
operator_id, lot_number, serial_number, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
@@ -52,11 +54,12 @@ async def download_measurement_report(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int | None = Query(None), subtask_id: int | None = Query(None),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Response: ) -> Response:
@@ -66,6 +69,7 @@ async def download_measurement_report(
db, recipe_id, subtask_id, db, recipe_id, subtask_id,
version_id, date_from, date_to, version_id, date_from, date_to,
operator_id, lot_number, serial_number, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
+109 -19
View File
@@ -1,5 +1,6 @@
"""System settings router - read/update settings, upload company logo.""" """System settings router - read/update settings, upload company logo."""
from pathlib import Path import json
from decimal import Decimal, InvalidOperation
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select from sqlalchemy import select
@@ -8,11 +9,88 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings from src.backend.config import settings
from src.backend.database import get_db from src.backend.database import get_db
from src.backend.api.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 src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, detect_file_type
from src.backend.models.orm.setting import SystemSetting from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.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"])
# Domains for the settings keys the backend actually reads. Values outside these
# would only fail later (csv.writer TypeError on close_run, etc) — reject up front.
_VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
_VALID_CSV_DECIMAL_SEPARATORS = {",", "."}
def _validate_string_list(key: str, value: str) -> None:
"""#49: array JSON di stringhe non vuote, max 50 voci (vuoto = nessuna lista)."""
try:
items = json.loads(value)
except ValueError:
items = None
if (
not isinstance(items, list)
or len(items) > 50
or not all(isinstance(i, str) and i.strip() for i in items)
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{key} deve essere vuoto o un array JSON di al massimo 50 stringhe non vuote",
)
def _validate_setting_value(key: str, value: str) -> None:
"""Raise 422 for a value that would break the code reading this key.
Unrecognised keys pass through unvalidated: the settings store is generic.
"""
if key == "csv_delimiter":
if value not in _VALID_CSV_DELIMITERS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="csv_delimiter deve essere un carattere singolo fra ',' ';' TAB '|'",
)
elif key == "csv_decimal_separator":
if value not in _VALID_CSV_DECIMAL_SEPARATORS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="csv_decimal_separator deve essere ',' o '.'",
)
elif key == "auto_logout_minutes":
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 480)):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="auto_logout_minutes deve essere vuoto (disabilitato) o un intero fra 1 e 480",
)
elif key == "default_measurement_interval_minutes":
# #47: vuoto = nessun default
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 1440)):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="default_measurement_interval_minutes deve essere vuoto o un intero fra 1 e 1440",
)
elif key in ("general_tolerance_plus", "general_tolerance_minus"):
# #48: decimale >= 0 con punto; vuoto = nessun default
if value != "":
try:
number = Decimal(value)
ok = number.is_finite() and number >= 0
except InvalidOperation:
ok = False
if not ok:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{key} deve essere vuoto o un numero decimale maggiore o uguale a 0 (punto decimale)",
)
elif key in ("list_measurement_types", "list_units"):
if value != "":
_validate_string_list(key, value)
elif key in ("company_name", "company_logo_path"):
if not value.strip() or len(value) > 200:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{key} deve essere una stringa non vuota di massimo 200 caratteri",
)
@router.get("/") @router.get("/")
async def get_settings( async def get_settings(
@@ -49,6 +127,8 @@ async def update_settings(
updated_keys = [] updated_keys = []
for key, value in settings_data.items(): for key, value in settings_data.items():
_validate_setting_value(key, value)
# Check if setting exists # Check if setting exists
result = await db.execute( result = await db.execute(
select(SystemSetting).where(SystemSetting.setting_key == key) select(SystemSetting).where(SystemSetting.setting_key == key)
@@ -88,35 +168,45 @@ async def upload_company_logo(
"""Upload company logo. """Upload company logo.
Saves logo to uploads/logos/ and updates company_logo_path setting. Saves logo to uploads/logos/ and updates company_logo_path setting.
Only admins can upload the logo. Only admins can upload the logo. The type is determined from the file's
magic bytes, not the client-declared Content-Type (#36 point 2). SVG is
refused outright: it can itself carry script content, and there is no
safe way to serve it inline for a logo shown on every page.
""" """
# Validate file type (must be image) # Read in chunks, stopping as soon as the size limit is crossed instead
allowed_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"} # of buffering an oversized body into memory first.
if file.content_type not in allowed_types: max_bytes = settings.max_upload_size_mb * 1024 * 1024
raise HTTPException( buffer = bytearray()
status_code=status.HTTP_400_BAD_REQUEST, chunk_size = 1024 * 1024
detail=f"File type {file.content_type} not allowed. Must be an image.", while True:
) chunk = await file.read(chunk_size)
if not chunk:
# Read file content break
content = await file.read() buffer.extend(chunk)
if len(buffer) > max_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
)
content = bytes(buffer)
file_size = len(content) file_size = len(content)
# Validate file size # Validate file type from the actual bytes (SVG never matches: refused)
max_bytes = settings.max_upload_size_mb * 1024 * 1024 detected = detect_file_type(content)
if file_size > max_bytes: if detected is None or detected[0] not in ALLOWED_IMAGE_TYPES:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB", detail="File type not allowed. Must be a recognised image (SVG is not accepted).",
) )
_detected_mime, detected_ext = detected
# Create logos directory # Create logos directory
logos_dir = settings.upload_path / "logos" logos_dir = settings.upload_path / "logos"
logos_dir.mkdir(parents=True, exist_ok=True) logos_dir.mkdir(parents=True, exist_ok=True)
# Use a fixed filename for the company logo # Fixed filename for the company logo, extension forced to what was
file_extension = Path(file.filename).suffix # actually detected (not what the client claimed or the filename said).
logo_filename = f"company_logo{file_extension}" logo_filename = f"company_logo{detected_ext}"
logo_path = logos_dir / logo_filename logo_path = logos_dir / logo_filename
# Write file # Write file
+96 -16
View File
@@ -3,11 +3,15 @@
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404. Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
NOT protected by API Key auth (standalone access). NOT protected by API Key auth (standalone access).
""" """
import asyncio
import hmac
import random import random
import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from src.backend.utils.tempo import iso_utc, utcnow
from pathlib import Path from pathlib import Path
from alembic import command
from alembic.config import Config
from fastapi import APIRouter, HTTPException, Request, status from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -22,15 +26,29 @@ from src.backend.models.orm import (
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
) )
from src.backend.models.orm.station import Station, StationRecipeAssignment from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.services.auth_service import hash_password from src.backend.services.auth_service import hash_password, issue_api_key, revoke_all_keys
from src.backend.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.parent / "templates" _backend_dir = Path(__file__).resolve().parent.parent.parent
_templates_dir = _backend_dir / "templates"
_alembic_ini = _backend_dir / "migrations" / "alembic.ini"
templates = Jinja2Templates(directory=str(_templates_dir)) templates = Jinja2Templates(directory=str(_templates_dir))
def _stamp_head() -> None:
"""Mark the schema as fully migrated - the equivalent of `alembic stamp
head` - after `Base.metadata.create_all()`.
A database that got its tables this way instead of through
`upgrade head` has no row in `alembic_version`; without this, its next
deploy would try to replay migrations 001-013 and fail on a column that
is already there (see migration 000_base_schema.py).
"""
command.stamp(Config(str(_alembic_ini)), "head")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Request schemas # Request schemas
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -87,13 +105,57 @@ def _check_setup_enabled() -> None:
def _check_password(password: str) -> None: def _check_password(password: str) -> None:
"""Verify the provided password matches the setup password.""" """Verify the provided password matches the setup password."""
_check_setup_enabled() _check_setup_enabled()
if password != settings.setup_password: if not hmac.compare_digest(password, settings.setup_password):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid setup password", detail="Invalid setup password",
) )
def _password_matches(password: str | None) -> bool:
"""Constant-time password check that tolerates a missing password."""
if not password or not settings.setup_password:
return False
return hmac.compare_digest(password, settings.setup_password)
async def _admin_exists() -> bool:
"""Whether at least one admin user has been created.
False on a database with no `users` table yet, so init-db and the first
seed are never blocked by a query against a table that does not exist.
"""
async with engine.connect() as conn:
has_users = await conn.run_sync(
lambda sync_conn: sa_inspect(sync_conn).has_table("users")
)
if not has_users:
return False
async with async_session_factory() as session:
result = await session.execute(select(User).where(User.is_admin == True)) # noqa: E712
return result.scalars().first() is not None
async def _require_not_initialised() -> None:
"""Refuse privileged/destructive setup endpoints once an admin exists.
reset-db, seed, manage-user, change-user-password and init-db all reach
into a live database once setup is done: reset-db wipes it, seed adds
demo users and data, manage-user/change-user-password can take over an
account, init-db races Alembic. All of them are meant for the one-time
bring-up before there is an admin to log in as.
`settings.setup_allow_after_init` is the explicit override for scripted
re-provisioning of a demo/test deploy.
"""
if settings.setup_allow_after_init:
return
if await _admin_exists():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="setup already completed",
)
def _generate_measurements_for_subtask( def _generate_measurements_for_subtask(
subtask: RecipeSubtask, subtask: RecipeSubtask,
version_id: int, version_id: int,
@@ -220,14 +282,24 @@ async def setup_page(request: Request):
@router.get("/status") @router.get("/status")
async def setup_status(): async def setup_status(password: str | None = None):
"""Return database table status (no password required, but setup must be enabled).""" """Database status. Without a valid password, only whether setup is done.
The table listing is only returned with the setup password: it is
internal detail, not something to hand to whoever happens to load the
page.
"""
_check_setup_enabled() _check_setup_enabled()
initialised = await _admin_exists()
if not _password_matches(password):
return {"initialised": initialised}
async with engine.connect() as conn: async with engine.connect() as conn:
table_names: list[str] = await conn.run_sync( table_names: list[str] = await conn.run_sync(
lambda sync_conn: sa_inspect(sync_conn).get_table_names() lambda sync_conn: sa_inspect(sync_conn).get_table_names()
) )
return { return {
"initialised": initialised,
"tables_exist": len(table_names) > 0, "tables_exist": len(table_names) > 0,
"table_count": len(table_names), "table_count": len(table_names),
"tables": table_names, "tables": table_names,
@@ -238,8 +310,12 @@ async def setup_status():
async def init_db(body: PasswordBody): async def init_db(body: PasswordBody):
"""Create all database tables.""" """Create all database tables."""
_check_password(body.password) _check_password(body.password)
await _require_not_initialised()
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
# in un thread non c'e' un loop attivo e funziona.
await asyncio.to_thread(_stamp_head)
return {"status": "ok", "message": "Database tables created successfully"} return {"status": "ok", "message": "Database tables created successfully"}
@@ -248,8 +324,6 @@ async def create_admin(body: CreateAdminBody):
"""Create an admin user with all roles.""" """Create an admin user with all roles."""
_check_password(body.password) _check_password(body.password)
api_key = secrets.token_hex(32)
async with async_session_factory() as session: async with async_session_factory() as session:
async with session.begin(): async with session.begin():
user = User( user = User(
@@ -259,9 +333,10 @@ async def create_admin(body: CreateAdminBody):
display_name=body.admin_display_name, display_name=body.admin_display_name,
roles=["Maker", "MeasurementTec", "Metrologist"], roles=["Maker", "MeasurementTec", "Metrologist"],
is_admin=True, is_admin=True,
api_key=api_key,
) )
session.add(user) session.add(user)
await session.flush()
api_key = await issue_api_key(session, user)
return { return {
"status": "ok", "status": "ok",
@@ -274,6 +349,7 @@ async def create_admin(body: CreateAdminBody):
async def seed_demo_data(body: PasswordBody): async def seed_demo_data(body: PasswordBody):
"""Seed database with demo users and a sample recipe.""" """Seed database with demo users and a sample recipe."""
_check_password(body.password) _check_password(body.password)
await _require_not_initialised()
async with async_session_factory() as session: async with async_session_factory() as session:
async with session.begin(): async with session.begin():
@@ -330,7 +406,6 @@ async def seed_demo_data(body: PasswordBody):
email=u["email"], email=u["email"],
roles=u["roles"], roles=u["roles"],
is_admin=u["is_admin"], is_admin=u["is_admin"],
api_key=secrets.token_hex(32),
) )
session.add(user) session.add(user)
created_users.append(user) created_users.append(user)
@@ -484,7 +559,7 @@ async def seed_demo_data(body: PasswordBody):
tec_user = created_users[2] # tec1 tec_user = created_users[2] # tec1
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"] lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)] serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
base_date = datetime.now() base_date = utcnow()
all_subtasks = subtasks_1 + subtasks_2 all_subtasks = subtasks_1 + subtasks_2
measurements_created = 0 measurements_created = 0
@@ -532,8 +607,8 @@ async def list_users(body: PasswordBody):
"roles": u.roles or [], "roles": u.roles or [],
"is_admin": u.is_admin, "is_admin": u.is_admin,
"active": u.active, "active": u.active,
"created_at": u.created_at.isoformat() if u.created_at else None, "created_at": iso_utc(u.created_at) or None,
"last_login": u.last_login.isoformat() if u.last_login else None, "last_login": iso_utc(u.last_login) or None,
} }
for u in users for u in users
], ],
@@ -544,6 +619,7 @@ async def list_users(body: PasswordBody):
async def manage_user(body: ManageUserBody): 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)
await _require_not_initialised()
invalid = set(body.roles) - VALID_ROLES invalid = set(body.roles) - VALID_ROLES
if invalid: if invalid:
@@ -577,7 +653,6 @@ async def manage_user(body: ManageUserBody):
email=body.email, email=body.email,
roles=body.roles, roles=body.roles,
is_admin=body.is_admin, is_admin=body.is_admin,
api_key=secrets.token_hex(32),
) )
session.add(user) session.add(user)
await session.flush() await session.flush()
@@ -618,6 +693,7 @@ async def manage_user(body: ManageUserBody):
async def change_user_password(body: ChangeUserPasswordBody): async def change_user_password(body: ChangeUserPasswordBody):
"""Change a user's password and invalidate their API key.""" """Change a user's password and invalidate their API key."""
_check_password(body.password) _check_password(body.password)
await _require_not_initialised()
async with async_session_factory() as session: async with async_session_factory() as session:
async with session.begin(): async with session.begin():
@@ -631,7 +707,7 @@ async def change_user_password(body: ChangeUserPasswordBody):
detail=f"User ID {body.user_id} not found", detail=f"User ID {body.user_id} not found",
) )
user.password_hash = hash_password(body.new_password) user.password_hash = hash_password(body.new_password)
user.api_key = None # Invalidate sessions await revoke_all_keys(session, user.id) # Invalidate sessions
return {"status": "ok", "message": f"Password changed for '{user.username}'"} return {"status": "ok", "message": f"Password changed for '{user.username}'"}
@@ -654,7 +730,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
) )
user.active = not user.active user.active = not user.active
if not user.active: if not user.active:
user.api_key = None # Invalidate sessions on deactivation await revoke_all_keys(session, user.id) # on deactivation
new_status = "active" if user.active else "inactive" new_status = "active" if user.active else "inactive"
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active} return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
@@ -664,6 +740,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
async def reset_db(body: ResetDbBody): async def reset_db(body: ResetDbBody):
"""Drop all tables and recreate them. Requires confirm=true.""" """Drop all tables and recreate them. Requires confirm=true."""
_check_password(body.password) _check_password(body.password)
await _require_not_initialised()
if not body.confirm: if not body.confirm:
raise HTTPException( raise HTTPException(
@@ -674,5 +751,8 @@ async def reset_db(body: ResetDbBody):
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
# in un thread non c'e' un loop attivo e funziona.
await asyncio.to_thread(_stamp_head)
return {"status": "ok", "message": "Database reset successfully"} return {"status": "ok", "message": "Database reset successfully"}
+57 -3
View File
@@ -11,6 +11,8 @@ from src.backend.models.api.station import (
StationResponse, StationResponse,
StationRecipeAssignmentCreate, StationRecipeAssignmentCreate,
StationRecipeAssignmentResponse, StationRecipeAssignmentResponse,
StationResetResponse,
StationWithRecipesResponse,
RecipeSummary, RecipeSummary,
) )
from src.backend.services import station_service from src.backend.services import station_service
@@ -18,15 +20,36 @@ 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)
@@ -44,6 +67,21 @@ async def create_new_station(
# The int-typed station_id param already guards against "by-code" being # The int-typed station_id param already guards against "by-code" being
# matched as a station id, but keeping the explicit order avoids surprises # matched as a station id, but keeping the explicit order avoids surprises
# during refactors (e.g. if someone regroups handlers by HTTP method). # during refactors (e.g. if someone regroups handlers by HTTP method).
@router.get("/by-code/{code}", response_model=StationResponse)
async def get_station_by_code(
code: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get a station by its code (any authenticated user, like /by-code/{code}/recipes) (#72)."""
station = await station_service.get_station_by_code(db, code)
if station is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Station not found",
)
return StationResponse.model_validate(station)
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary]) @router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
async def list_recipes_by_station_code( async def list_recipes_by_station_code(
code: str, code: str,
@@ -128,6 +166,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,
+149 -11
View File
@@ -1,15 +1,18 @@
"""Statistics router - SPC endpoints for Metrologist dashboard.""" """Statistics router - SPC endpoints for Metrologist dashboard."""
from datetime import datetime from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, select from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_metrologist from src.backend.api.middleware.api_key import require_metrologist
from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import RecipeVersion from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.models.api.statistics import ( from src.backend.models.api.statistics import (
CapabilityData, CapabilityData,
@@ -37,6 +40,7 @@ async def _query_measurements(
operator_id: int | None = None, operator_id: int | None = None,
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
production_run_id: int | None = None, # #58: solo le misure di una sessione
) -> list[Measurement]: ) -> list[Measurement]:
"""Query measurements with common filters. """Query measurements with common filters.
@@ -66,6 +70,9 @@ async def _query_measurements(
if serial_number is not None: if serial_number is not None:
filters.append(Measurement.serial_number == serial_number) filters.append(Measurement.serial_number == serial_number)
if production_run_id is not None:
filters.append(Measurement.production_run_id == production_run_id)
query = ( query = (
select(Measurement) select(Measurement)
.where(and_(*filters) if filters else True) .where(and_(*filters) if filters else True)
@@ -100,11 +107,12 @@ async def get_summary(
recipe_id: int = Query(...), recipe_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
subtask_id: int | None = Query(None), subtask_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> SummaryData: ) -> SummaryData:
@@ -112,6 +120,7 @@ async def get_summary(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
pass_fail_values = [m.pass_fail for m in measurements] pass_fail_values = [m.pass_fail for m in measurements]
return compute_summary(pass_fail_values) return compute_summary(pass_fail_values)
@@ -122,11 +131,12 @@ async def get_capability(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> CapabilityData: ) -> CapabilityData:
@@ -134,8 +144,10 @@ async def get_capability(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
values = [float(m.value) for m in measurements] # #118: le quote NaN (value None) non entrano nei calcoli
values = [float(m.value) for m in measurements if m.value is not None]
tol = await _get_subtask_tolerances(db, subtask_id) tol = await _get_subtask_tolerances(db, subtask_id)
return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"]) return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"])
@@ -145,11 +157,12 @@ async def get_control_chart(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ControlChartData: ) -> ControlChartData:
@@ -157,7 +170,10 @@ async def get_control_chart(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
# #118: le quote NaN (value None) si saltano, valori e istanti restano allineati
measurements = [m for m in measurements if m.value is not None]
values = [float(m.value) for m in measurements] values = [float(m.value) for m in measurements]
timestamps = [m.measured_at for m in measurements] timestamps = [m.measured_at for m in measurements]
tol = await _get_subtask_tolerances(db, subtask_id) tol = await _get_subtask_tolerances(db, subtask_id)
@@ -172,11 +188,12 @@ async def get_histogram(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
n_bins: int = Query(20, ge=5, le=100), n_bins: int = Query(20, ge=5, le=100),
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -185,8 +202,10 @@ async def get_histogram(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
values = [float(m.value) for m in measurements] # #118: le quote NaN (value None) non entrano nell'istogramma
values = [float(m.value) for m in measurements if m.value is not None]
return compute_histogram(values, n_bins) return compute_histogram(values, n_bins)
@@ -234,3 +253,122 @@ async def get_recipe_subtasks(
}) })
return subtasks_list return subtasks_list
@router.get("/runs")
async def get_recipe_runs(
recipe_id: int = Query(...),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> list[dict]:
"""#58: production runs of a recipe (for the "Sessione" filter), newest first."""
runs = (await db.execute(
select(ProductionRun)
.where(ProductionRun.recipe_id == recipe_id)
.order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc())
)).scalars().all()
# #58: conta solo le misure dei run della ricetta, non tutta la tabella.
run_ids = [r.id for r in runs]
counts = {}
if run_ids:
counts = dict((await db.execute(
select(Measurement.production_run_id, func.count(Measurement.id))
.where(Measurement.production_run_id.in_(run_ids))
.group_by(Measurement.production_run_id)
)).all())
operator_ids = {r.operator_id for r in runs}
names = {}
if operator_ids:
names = dict((await db.execute(
select(User.id, User.display_name).where(User.id.in_(operator_ids))
)).all())
return [
{
"id": r.id,
"station_id": r.station_id,
"station_name": r.station.name if r.station else None,
"operator_id": r.operator_id,
"operator_name": names.get(r.operator_id),
"started_at": r.started_at.isoformat() if r.started_at else None,
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
"status": r.status,
"lot_number": r.lot_number,
"serial_number": r.serial_number,
"cycle_count": r.cycle_count,
"measurement_count": counts.get(r.id, 0),
}
for r in runs
]
@router.get("/runs/{run_id}/task-results")
async def get_run_task_results(
run_id: int,
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> dict:
"""#58: esiti, note, campi e allegati dei task di una sessione di produzione.
Endpoint di statistica (require_metrologist): GET /api/task-results vuole
il ruolo MeasurementTec e non si puo' riusare qui.
"""
run = (await db.execute(
select(ProductionRun.id).where(ProductionRun.id == run_id)
)).scalar_one_or_none()
if run is None:
raise HTTPException(status_code=404, detail="Sessione non trovata")
# #58: una query per i risultati della run, una per i task, una per gli utenti.
results = (await db.execute(
select(TaskResult)
.where(TaskResult.production_run_id == run_id)
.order_by(TaskResult.created_at.asc(), TaskResult.id.asc())
)).scalars().all()
task_ids = {r.task_id for r in results}
tasks = {}
if task_ids:
tasks = {t.id: t for t in (await db.execute(
select(RecipeTask).where(RecipeTask.id.in_(task_ids))
)).scalars().all()}
user_ids = {r.user_id for r in results}
names = {}
if user_ids:
names = dict((await db.execute(
select(User.id, User.display_name).where(User.id.in_(user_ids))
)).all())
counts = {"ok": 0, "nok": 0, "done": 0}
out = []
for r in results:
counts[r.outcome] = counts.get(r.outcome, 0) + 1
task = tasks.get(r.task_id)
# #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave.
labels = {
f.get("key"): f.get("label") or f.get("key")
for f in (task.fields or []) if isinstance(f, dict)
} if task else {}
out.append({
"id": r.id,
"task_id": r.task_id,
"task_title": task.title if task else None,
"task_type": task.task_type if task else None,
"outcome": r.outcome,
"note": r.note,
"created_at": r.created_at.isoformat() if r.created_at else None,
"user_name": names.get(r.user_id),
"values": [
{"key": k, "label": labels.get(k, k), "value": v}
for k, v in (r.values or {}).items()
],
"attachments": [
{
"key": a.get("key"),
"label": labels.get(a.get("key"), a.get("key")),
"name": a.get("name"),
"path": a.get("path"),
}
for a in (r.attachments or []) if isinstance(a, dict)
],
})
return {"run_id": run_id, "counts": counts, "results": out}
+192
View File
@@ -0,0 +1,192 @@
"""Task results router: l'esito di un task 'check' (OK/NOK) o 'note' (#54 #55).
Un 'check' registra ok o nok (nok vuole la nota); un 'note' registra 'done' con i
valori dei campi extra e gli allegati. I task di misura non passano di qui: le
loro letture sono Measurement.
"""
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.middleware.api_key import require_measurement_tec
from src.backend.api.routers.files import (
_UPLOAD_CHUNK_SIZE, detect_file_type, sanitize_filename,
)
from src.backend.config import settings
from src.backend.database import get_db
from src.backend.models.api.task_result import TaskResultCreate, TaskResultResponse
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.task_result import TaskResult
from src.backend.models.orm.user import User
router = APIRouter(prefix="/api/task-results", tags=["task-results"])
def _unprocessable(detail: str) -> HTTPException:
# 422 con detail testuale: l'api_client del frontend legge solo `detail`
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
# Radice degli allegati degli esiti: sotto upload_path, mai cancellabile (audit).
ATTACHMENT_DIR = "task_results"
def _safe_relative_path(path: str) -> bool:
"""Allegato: relativo, dentro task_results/, senza '..', unita' o backslash."""
if path.startswith("/") or "\\" in path or ":" in path:
return False
parts = path.split("/")
return ".." not in parts and parts[0] == ATTACHMENT_DIR and len(parts) > 1
@router.post("/attachments", status_code=status.HTTP_201_CREATED)
async def upload_attachment(
file: UploadFile = File(...),
_user: User = Depends(require_measurement_tec),
):
"""Carica l'allegato di un task 'note' (l'operatore non ha accesso a /api/files/upload).
Stessi controlli di upload_file: tipo dai magic bytes, limite di dimensione
con lettura a blocchi, nome ripulito. Salva sotto task_results/<AAAA>/<MM>/.
"""
max_bytes = settings.max_upload_size_mb * 1024 * 1024
buffer = bytearray()
while True:
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
if not chunk:
break
buffer.extend(chunk)
if len(buffer) > max_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
)
content = bytes(buffer)
detected = detect_file_type(content)
if detected is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File type not allowed. Must be a recognised image or PDF.",
)
_mime, ext = detected
# nome originale ripulito (per mostrarlo); sul disco solo uuid + estensione rilevata
name = sanitize_filename(file.filename or "")
now = datetime.now(timezone.utc)
storage_dir = settings.upload_path / ATTACHMENT_DIR / f"{now:%Y}" / f"{now:%m}"
storage_dir.mkdir(parents=True, exist_ok=True)
target = storage_dir / f"{uuid.uuid4().hex}{ext}"
target.write_bytes(content)
return {
"file_path": str(target.relative_to(settings.upload_path)).replace("\\", "/"),
"name": name,
}
@router.post("", response_model=TaskResultResponse, status_code=status.HTTP_201_CREATED)
async def create_task_result(
data: TaskResultCreate,
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Registra l'esito di un task 'check' o 'note'."""
task = (await db.execute(
select(RecipeTask).where(RecipeTask.id == data.task_id)
)).scalar_one_or_none()
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
note = data.note.strip() if data.note else None
note = note or None
values: Optional[dict] = None
attachments: Optional[list] = None
if task.task_type == "check":
if data.outcome not in ("ok", "nok"):
raise _unprocessable("Un controllo si registra come ok o nok")
if data.outcome == "nok" and not note:
raise _unprocessable("Per un NOK serve la nota")
elif task.task_type == "note":
if data.outcome != "done":
raise _unprocessable("Una nota si registra come done")
fields = {f["key"]: f for f in (task.fields or [])}
values = {}
for key, value in (data.values or {}).items():
if key not in fields:
raise _unprocessable(f"Campo '{key}' non previsto dal task")
if fields[key]["type"] == "number" and value.strip() != "":
try:
float(value.replace(",", "."))
except ValueError:
raise _unprocessable(f"Il campo '{fields[key]['label']}' deve essere un numero")
values[key] = value
attachments = []
for att in data.attachments or []:
if not _safe_relative_path(att.path):
raise _unprocessable(
f"Percorso allegato non valido (deve stare in {ATTACHMENT_DIR}/): {att.path}"
)
if att.key is not None and att.key not in fields:
raise _unprocessable(f"Campo allegato '{att.key}' non previsto dal task")
attachments.append(att.model_dump())
values = values or None
attachments = attachments or None
else:
raise _unprocessable(
f"I task di tipo '{task.task_type}' non registrano un esito"
)
if data.production_run_id is not None:
run = (await db.execute(
select(ProductionRun).where(ProductionRun.id == data.production_run_id)
)).scalar_one_or_none()
if run is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found"
)
if run.status == "closed":
raise _unprocessable("La produzione e' gia' chiusa")
if run.version_id != task.version_id:
raise _unprocessable("Il task non appartiene alla versione di questa produzione")
result = TaskResult(
task_id=task.id,
version_id=task.version_id, # presa dal task, non dal client
production_run_id=data.production_run_id,
user_id=user.id,
outcome=data.outcome,
note=note,
values=values,
attachments=attachments,
)
db.add(result)
await db.flush()
await db.refresh(result) # created_at e' un server_default
return TaskResultResponse.model_validate(result)
@router.get("", response_model=list[TaskResultResponse])
async def list_task_results(
production_run_id: Optional[int] = Query(None, gt=0),
task_id: Optional[int] = Query(None, gt=0),
_user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Esiti registrati, filtrabili per produzione e/o task; il piu' recente prima."""
stmt = select(TaskResult)
if production_run_id is not None:
stmt = stmt.where(TaskResult.production_run_id == production_run_id)
if task_id is not None:
stmt = stmt.where(TaskResult.task_id == task_id)
rows = (await db.execute(
stmt.order_by(TaskResult.created_at.desc(), TaskResult.id.desc())
)).scalars().all()
return [TaskResultResponse.model_validate(r) for r in rows]
+165 -13
View File
@@ -17,8 +17,13 @@ from src.backend.models.api.task import (
TaskReorderRequest, TaskReorderRequest,
TaskResponse, TaskResponse,
TaskUpdate, TaskUpdate,
check_task_loop_options,
default_in_loop,
normalize_note_fields,
validate_tolerance_order,
) )
from src.backend.services import recipe_service from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.services import recipe_service, vision_service
router = APIRouter(tags=["tasks"]) router = APIRouter(tags=["tasks"])
@@ -99,6 +104,64 @@ async def _ensure_version_is_current(db: AsyncSession, version_id: int) -> Recip
return version return version
async def _task_for_write(db: AsyncSession, task: RecipeTask, user: User) -> RecipeTask:
"""Return the task to write to: the same one, or its copy in a new version.
A version that already has measurements cannot be edited on the spot - its
recorded pass/fail would silently start meaning something else. Copy-on-write
(the same one create_task/update_recipe use) gives the edit a fresh version
and leaves the measured one exactly as it was when the readings were taken.
The task is matched in the copy by its position (order_index), which
_copy_tasks_to_version preserves exactly.
"""
if not await recipe_service.version_has_measurements(db, task.version_id):
return task
recipe_id = task.recipe_id
if recipe_id is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot determine the recipe for this task",
)
from src.backend.models.api.recipe import RecipeUpdate
new_version = await recipe_service.create_new_version(
db, recipe_id, RecipeUpdate(change_notes=f"Edited task: {task.title}"), user,
)
for candidate in new_version.tasks:
if candidate.order_index == task.order_index:
# TaskResponse.recipe_id reads task.version.recipe_id; new_version's
# own tasks are loaded via selectinload, which does not also
# populate the child->parent backref, so left alone this would
# lazy-load .version outside of an awaited context and crash.
candidate.version = new_version
return candidate
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Could not locate the task in the new version",
)
async def _subtask_for_write(
db: AsyncSession, task: RecipeTask, subtask: RecipeSubtask, user: User,
) -> tuple[RecipeTask, RecipeSubtask]:
"""Return (task, subtask) to write to: the same ones, or their copies.
Same copy-on-write as _task_for_write, one level down: once the task's copy
is found, the quote is matched inside it by marker_number (unique per task).
"""
new_task = await _task_for_write(db, task, user)
if new_task is task:
return task, subtask
for candidate in new_task.subtasks:
if candidate.marker_number == subtask.marker_number:
return new_task, candidate
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Could not locate the quote in the new version",
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Task endpoints # Task endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -142,6 +205,13 @@ async def create_task(
This creates a NEW version via copy-on-write, then appends the task to This creates a NEW version via copy-on-write, then appends the task to
the new version. Requires Maker role. the new version. Requires Maker role.
""" """
# #53 #55: validazione di in_loop/at_close/fields prima di toccare il db
try:
fields = normalize_note_fields(data.fields)
check_task_loop_options(data.task_type, data.at_close, fields)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
# Ensure recipe exists and is active # Ensure recipe exists and is active
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id)) result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
recipe = result.scalar_one_or_none() recipe = result.scalar_one_or_none()
@@ -172,12 +242,20 @@ async def create_task(
new_task = RecipeTask( new_task = RecipeTask(
version_id=new_version.id, version_id=new_version.id,
order_index=max_order + 1, order_index=max_order + 1,
task_type=data.task_type,
title=data.title, title=data.title,
directive=data.directive, directive=data.directive,
description=data.description, description=data.description,
file_path=data.file_path, file_path=data.file_path,
file_type=data.file_type, file_type=data.file_type,
annotations_json=data.annotations_json, annotations_json=data.annotations_json,
vision_json=data.vision_json,
# #97: camera e modo di analisi (dict gia' validato dal modello)
acquisition_json=data.acquisition_json.model_dump() if data.acquisition_json else None,
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
at_close=data.at_close,
fields=fields,
) )
db.add(new_task) db.add(new_task)
await db.flush() await db.flush()
@@ -196,6 +274,7 @@ async def create_task(
ltl=sub_data.ltl, ltl=sub_data.ltl,
unit=sub_data.unit, unit=sub_data.unit,
image_path=sub_data.image_path, image_path=sub_data.image_path,
vision_output=sub_data.vision_output,
) )
db.add(sub) db.add(sub)
@@ -261,10 +340,16 @@ async def reorder_tasks(
async def update_task( async def update_task(
task_id: int, task_id: int,
data: TaskUpdate, data: TaskUpdate,
_user: User = Depends(require_maker), user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Update a task. The task must belong to the current version. Requires Maker role.""" """Update a task. The task must belong to the current version. Requires Maker role.
If that version already has measurements the edit goes through copy-on-write:
a new version is created and its copy of the task is the one changed. The
version with measurements is left untouched, so its recorded pass/fail keeps
meaning what it meant when it was taken.
"""
task = await _get_task_or_404(db, task_id) task = await _get_task_or_404(db, task_id)
await _ensure_version_is_current(db, task.version_id) await _ensure_version_is_current(db, task.version_id)
@@ -272,6 +357,28 @@ async def update_task(
if not update_data: if not update_data:
return TaskResponse.model_validate(task) return TaskResponse.model_validate(task)
# #53 #55: i tre campi nuovi si validano sulla combinazione finale (valori
# nuovi, o quelli gia' sul task per cio' che il client non manda)
if "fields" in update_data:
try:
update_data["fields"] = normalize_note_fields(update_data["fields"])
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
for flag in ("in_loop", "at_close"):
if flag in update_data and update_data[flag] is None:
del update_data[flag] # i booleani non sono nullable: null = non toccare
final_type = update_data.get("task_type") or task.task_type
try:
check_task_loop_options(
final_type,
update_data.get("at_close", task.at_close),
update_data.get("fields", task.fields),
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
task = await _task_for_write(db, task, user)
for field, value in update_data.items(): for field, value in update_data.items():
setattr(task, field, value) setattr(task, field, value)
@@ -283,18 +390,37 @@ async def update_task(
@router.delete("/api/tasks/{task_id}", status_code=204) @router.delete("/api/tasks/{task_id}", status_code=204)
async def delete_task( async def delete_task(
task_id: int, task_id: int,
_user: User = Depends(require_maker), user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Delete a task and its subtasks. The task must belong to the current version. """Delete a task and its subtasks. The task must belong to the current version.
Requires Maker role. Requires Maker role. Copy-on-write applies as in update_task: a version with
measurements is never touched, the deletion lands on its copy instead.
""" """
task = await _get_task_or_404(db, task_id) task = await _get_task_or_404(db, task_id)
await _ensure_version_is_current(db, task.version_id) await _ensure_version_is_current(db, task.version_id)
task = await _task_for_write(db, task, user)
# The rows go with the task (migration 014 cascades them), the files do not:
# collect them now, remove the files only once the commit is durable - same
# order as vision.py::delete_reference_image. Rows are deleted explicitly so
# file_still_referenced sees the post-delete state even where the database
# does not enforce the cascade.
references = (await db.execute(
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
)).scalars().all()
for reference in references:
await db.delete(reference)
await db.delete(task) await db.delete(task)
await db.flush() await db.commit()
# A copy-on-write sibling may still point at the same file (#38.6).
for reference in references:
if not await vision_service.file_still_referenced(db, reference.path):
vision_service.remove_reference_file(reference)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -309,16 +435,19 @@ async def delete_task(
async def create_subtask( async def create_subtask(
task_id: int, task_id: int,
data: SubtaskCreate, data: SubtaskCreate,
_user: User = Depends(require_maker), user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Add a subtask to a task. The task must belong to the current version. """Add a subtask to a task. The task must belong to the current version.
Requires Maker role. Requires Maker role. Copy-on-write applies as in create_task: a version with
measurements gets a new version, and the subtask is added there instead.
""" """
task = await _get_task_or_404(db, task_id) task = await _get_task_or_404(db, task_id)
await _ensure_version_is_current(db, task.version_id) await _ensure_version_is_current(db, task.version_id)
task = await _task_for_write(db, task, user)
# Check marker_number uniqueness within the task # Check marker_number uniqueness within the task
existing_markers = {s.marker_number for s in task.subtasks} existing_markers = {s.marker_number for s in task.subtasks}
if data.marker_number in existing_markers: if data.marker_number in existing_markers:
@@ -328,7 +457,7 @@ async def create_subtask(
) )
subtask = RecipeSubtask( subtask = RecipeSubtask(
task_id=task_id, task_id=task.id,
marker_number=data.marker_number, marker_number=data.marker_number,
description=data.description, description=data.description,
measurement_type=data.measurement_type, measurement_type=data.measurement_type,
@@ -339,6 +468,7 @@ async def create_subtask(
ltl=data.ltl, ltl=data.ltl,
unit=data.unit, unit=data.unit,
image_path=data.image_path, image_path=data.image_path,
vision_output=data.vision_output,
) )
db.add(subtask) db.add(subtask)
await db.flush() await db.flush()
@@ -350,12 +480,13 @@ async def create_subtask(
async def update_subtask( async def update_subtask(
subtask_id: int, subtask_id: int,
data: SubtaskUpdate, data: SubtaskUpdate,
_user: User = Depends(require_maker), user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Update a subtask. Its parent task must belong to the current version. """Update a subtask. Its parent task must belong to the current version.
Requires Maker role. Requires Maker role. Copy-on-write applies as in update_task, one level
down: a version with measurements is never touched, the quote's copy is.
""" """
subtask = await _get_subtask_or_404(db, subtask_id) subtask = await _get_subtask_or_404(db, subtask_id)
@@ -367,6 +498,24 @@ async def update_subtask(
if not update_data: if not update_data:
return SubtaskResponse.model_validate(subtask) return SubtaskResponse.model_validate(subtask)
# SubtaskUpdate is partial, so the order can only be checked against the
# values the write would actually leave in place: existing + incoming.
def _resulting(field: str) -> float | None:
value = update_data.get(field, getattr(subtask, field))
return None if value is None else float(value)
try:
validate_tolerance_order(
_resulting("ltl"), _resulting("lwl"), _resulting("nominal"),
_resulting("uwl"), _resulting("utl"),
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc),
)
task, subtask = await _subtask_for_write(db, task, subtask, user)
for field, value in update_data.items(): for field, value in update_data.items():
setattr(subtask, field, value) setattr(subtask, field, value)
@@ -378,12 +527,13 @@ async def update_subtask(
@router.delete("/api/subtasks/{subtask_id}", status_code=204) @router.delete("/api/subtasks/{subtask_id}", status_code=204)
async def delete_subtask( async def delete_subtask(
subtask_id: int, subtask_id: int,
_user: User = Depends(require_maker), user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Delete a subtask. Its parent task must belong to the current version. """Delete a subtask. Its parent task must belong to the current version.
Requires Maker role. Requires Maker role. Copy-on-write applies as in update_task, one level
down: a version with measurements is never touched, the quote's copy is.
""" """
subtask = await _get_subtask_or_404(db, subtask_id) subtask = await _get_subtask_or_404(db, subtask_id)
@@ -391,5 +541,7 @@ async def delete_subtask(
task = await _get_task_or_404(db, subtask.task_id) task = await _get_task_or_404(db, subtask.task_id)
await _ensure_version_is_current(db, task.version_id) await _ensure_version_is_current(db, task.version_id)
task, subtask = await _subtask_for_write(db, task, subtask, user)
await db.delete(subtask) await db.delete(subtask)
await db.flush() await db.flush()
+77 -6
View File
@@ -1,13 +1,19 @@
"""Users router - CRUD operations (admin only).""" """Users router - CRUD operations (admin only)."""
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_admin_user from src.backend.api.middleware.api_key import require_admin_user
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate from src.backend.models.api.user import PinSet, UserCreate, UserPasswordChange, UserResponse, UserUpdate
from src.backend.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,
revoke_all_keys,
set_user_pin,
)
router = APIRouter(prefix="/api/users", tags=["users"]) router = APIRouter(prefix="/api/users", tags=["users"])
@@ -78,6 +84,21 @@ async def update_user(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
# roles and is_admin are NOT NULL columns: an explicit null passed the
# Optional[...] schema check but blew up at flush time as an unhandled
# 500 (#36 point 4).
if "roles" in update_data and update_data["roles"] is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="roles cannot be null",
)
if "is_admin" in update_data and update_data["is_admin"] is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="is_admin cannot be null",
)
# Validate roles if provided # Validate roles if provided
if "roles" in update_data: if "roles" in update_data:
for role in update_data["roles"]: for role in update_data["roles"]:
@@ -86,6 +107,27 @@ async def update_user(
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}",
) )
# Losing the last admin locks everyone out of user management; losing
# your own admin flag while acting as admin is the same mistake aimed
# at yourself. Neither is reversible without direct DB access.
if update_data.get("is_admin") is False and user.is_admin:
if user.id == admin.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot remove your own admin privilege",
)
other_admins = await db.execute(
select(func.count()).select_from(User).where(
User.is_admin == True, User.id != user.id # noqa: E712
)
)
if other_admins.scalar_one() == 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot remove admin privilege from the last admin",
)
for field, value in update_data.items(): for field, value in update_data.items():
setattr(user, field, value) setattr(user, field, value)
await db.flush() await db.flush()
@@ -100,16 +142,45 @@ async def change_user_password(
admin: User = Depends(require_admin_user), admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Change user password (admin only).""" """Change user password (admin only). Invalidates the user's API key,
the same as setup.py's change-user-password: a password change should
force everyone logged in as that user to log back in.
"""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.password_hash = hash_password(data.password) user.password_hash = hash_password(data.password)
await revoke_all_keys(db, user.id)
await db.flush() await db.flush()
return {"message": f"Password changed for user {user.username}"} return {"message": f"Password changed for user {user.username}"}
@router.put("/{user_id}/pin", status_code=status.HTTP_200_OK)
async def change_user_pin(
user_id: int,
data: PinSet,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Imposta o toglie (pin null) il PIN di un utente (admin, #59/#51).
422 se l'utente e' admin. Il PIN non si restituisce mai.
"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.is_admin:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Admin users cannot have a PIN",
)
await set_user_pin(db, user, data.pin)
return {"message": f"PIN {'set' if data.pin else 'removed'} for user {user.username}"}
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_user( async def deactivate_user(
user_id: int, user_id: int,
@@ -127,7 +198,7 @@ async def deactivate_user(
detail="Cannot deactivate yourself", detail="Cannot deactivate yourself",
) )
user.active = False user.active = False
user.api_key = None await revoke_all_keys(db, user.id)
await db.flush() await db.flush()
@@ -142,5 +213,5 @@ async def regenerate_user_key(
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
new_key = await regenerate_api_key(db, user_id) new_key = await regenerate_api_key(db, user)
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"} return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
+500
View File
@@ -0,0 +1,500 @@
"""The client asks for a measurement and gets an outcome.
It does not know, and must not know, whether the server or the station computed
the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes.
"""
import base64
import binascii
import io
import json
import mimetypes
from typing import Literal
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from PIL import Image
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
from src.backend.api.routers.files import (
ALLOWED_IMAGE_TYPES, resolve_upload_path, validate_file_size,
)
from src.backend.config import settings
from src.backend.database import get_db
from src.backend.models.api.vision import (
VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionRecipeUploadResponse, VisionReferenceImageResponse,
)
from src.backend.models.orm.vision import VisionResult
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import station_service, vision_service
router = APIRouter(prefix="/api/vision", tags=["vision"])
# A vision graph measures pixels, not a PDF: stricter than files.py's
# ALLOWED_TYPES, which also accepts documents for technical drawings.
_IMAGE_EXTENSIONS = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp", # #99
"image/x-ms-bmp": "bmp", # #99
}
# #99: the Opto bench cameras save BMP and the worker decodes it (PIL), so the
# vision routes accept it on top of files.py's list. files.py is untouched: its
# attachments are served by extension and BMP is not meant to be shown there.
_VISION_IMAGE_TYPES = ALLOWED_IMAGE_TYPES | {"image/bmp", "image/x-ms-bmp"}
# Sniffed from the first bytes, independent of the declared Content-Type: a
# text file uploaded as "image/png" passes the header check above and must
# still be caught before it reaches the worker as a bare 500 (#15). TIFF is
# included even though the allow-list does not accept it (PIL, and so the
# worker, can decode it; browsers do not show it in the result); BMP is now
# also allowed (#99). This check only refuses what is not a real image at all,
# it does not narrow the allow-list above.
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
(b"\x89PNG\r\n\x1a\n",), # PNG
(b"\xff\xd8\xff",), # JPEG
(b"BM",), # BMP
(b"II*\x00", b"MM\x00*"), # TIFF, little- and big-endian
(b"GIF87a", b"GIF89a"), # GIF
)
def _looks_like_an_image(content: bytes) -> bool:
"""True if `content` starts with a known image format's magic bytes."""
for signatures in _IMAGE_SIGNATURES:
if any(content.startswith(sig) for sig in signatures):
return True
# WebP: "RIFF" + 4-byte size + "WEBP", the size in between is not fixed.
return len(content) >= 12 and content[:4] == b"RIFF" and content[8:12] == b"WEBP"
async def _task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
"""Shared by the endpoints of this router."""
task = (await db.execute(
select(RecipeTask).where(RecipeTask.id == task_id)
)).scalar_one_or_none()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
return task
async def _read_image(image: UploadFile) -> bytes:
"""Type checked from the header before reading, size checked once the body
is in hand - the same two-step convention `files.py::upload_file` already
uses, reused rather than reinvented here.
"""
if image.content_type not in _VISION_IMAGE_TYPES: # #99
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {image.content_type} not allowed. Must be an image.",
)
content = await image.read()
if not validate_file_size(len(content)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"File size {len(content)} bytes exceeds maximum "
f"{settings.max_upload_size_mb}MB"
),
)
if not _looks_like_an_image(content):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="the file's content does not match a supported image format",
)
_reject_animated(content)
return content
def _reject_animated(content: bytes) -> None:
"""Animated GIF/WebP/APNG have no single frame to measure (decision of
06/10/2026, #43): refuse them instead of silently reading frame 0."""
try:
with Image.open(io.BytesIO(content)) as img:
animated = getattr(img, "is_animated", False) or getattr(img, "n_frames", 1) > 1
except Exception:
# Unreadable here is not this check's business: the magic bytes passed
# and the worker decides what it can decode.
return
if animated:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="animated images are not supported",
)
@router.post("/execute", response_model=VisionExecuteResponse)
async def execute(
task_id: int = Form(...),
image: UploadFile = File(...),
lot_number: str | None = Form(None, max_length=100),
serial_number: str | None = Form(None, max_length=100),
production_run_id: int | None = Form(None, gt=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> VisionExecuteResponse:
task = await _task_or_404(db, task_id)
result, saved, report = await vision_service.execute_task(
db,
task,
await _read_image(image),
current_user.id,
version_id=task.version_id,
lot_number=lot_number,
serial_number=serial_number,
production_run_id=production_run_id,
image_ext=_IMAGE_EXTENSIONS.get(image.content_type, "png"),
)
await db.commit()
return VisionExecuteResponse(
vision_result_id=result.id,
engine_version=result.engine_version,
measurements=[
VisionMeasurementResult(
subtask_id=m.subtask_id,
# #118: None = quota non prodotta, il frontend la mostra NaN
value=float(m.value) if m.value is not None else None,
pass_fail=m.pass_fail,
)
for m in saved
],
outcome=report.get("outcome"),
overlay=report.get("overlay") or [],
image_url=f"/api/vision/results/{result.id}/image",
failures=report.get("failures") or [],
)
@router.post("/check", response_model=VisionCheckResponse)
async def check(
task_id: int = Form(...),
image: UploadFile = File(...),
production_run_id: int | None = Form(None, gt=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> VisionCheckResponse:
"""#97: analisi di un frame senza salvare nulla (ne' risultato ne' misure)."""
task = await _task_or_404(db, task_id)
report = await vision_service.check_task(
db, task, await _read_image(image), production_run_id=production_run_id,
)
return VisionCheckResponse(**report)
@router.get("/results/{result_id}/image")
async def get_result_image(
result_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> FileResponse:
"""The image a vision run measured, as saved by `execute` (#46)."""
result = (await db.execute(
select(VisionResult).where(VisionResult.id == result_id)
)).scalar_one_or_none()
if result is None or not result.image_path:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Result image not found",
)
return FileResponse(resolve_upload_path(result.image_path))
def _422(errors: list[str]) -> HTTPException:
# detail e' una lista di stringhe: il frontend legge i 422 solo da `detail`.
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=errors,
)
async def _store_validate_assign(
task: RecipeTask, graph: dict, placed: dict[str, bytes],
) -> tuple[str, dict]:
"""Scrive la cartella ricetta, la fa validare dal worker, la assegna al task.
Condiviso da upload_recipe e dxf-compare con save (#83): stesso
comportamento, un solo posto. Non committa. Ritorna (recipe_dir, check).
"""
# The folder is content-addressed and immutable; writing it before the
# validation is harmless (an orphan folder is never referenced) and the
# worker needs it on disk to validate meta files.
try:
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
except ValueError as exc:
raise _422([str(exc)])
check = await vision_service.validate_recipe(graph, recipe_dir)
if not check["ok"]:
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
task.vision_json = graph
task.vision_recipe_dir = recipe_dir
return recipe_dir, check
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
async def upload_recipe(
task_id: int,
recipe: UploadFile = File(...),
files: list[UploadFile] = File(default=[]),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> VisionRecipeUploadResponse:
"""Load a VisionSuite v2 recipe plus its meta files onto a task (#46)."""
task = await _task_or_404(db, task_id)
if task.task_type not in vision_service.VISION_TASK_TYPES:
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
raw = await recipe.read()
if not validate_file_size(len(raw)):
raise _422([f"recipe exceeds {settings.max_upload_size_mb}MB"])
try:
graph = json.loads(raw)
except ValueError:
raise _422(["the recipe is not valid JSON"])
if not isinstance(graph, dict):
raise _422(["the recipe must be a JSON object"])
uploads: list[tuple[str, bytes]] = []
for f in files:
content = await f.read()
if not validate_file_size(len(content)):
raise _422([f"file {f.filename!r} exceeds {settings.max_upload_size_mb}MB"])
uploads.append((f.filename or "", content))
placed, errors = vision_service.match_recipe_files(graph, uploads)
if errors:
raise _422(errors)
recipe_dir, check = await _store_validate_assign(task, graph, placed)
await db.commit()
return VisionRecipeUploadResponse(
task_id=task.id,
recipe_dir=recipe_dir,
files=vision_service.list_recipe_files(recipe_dir),
outputs=check["outputs"],
)
@router.get("/tasks/{task_id}/recipe", response_model=VisionRecipeResponse)
async def get_recipe(
task_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> VisionRecipeResponse:
task = await _task_or_404(db, task_id)
outputs: list[dict] = []
if task.vision_json:
check = await vision_service.validate_recipe(
task.vision_json, task.vision_recipe_dir,
)
outputs = check.get("outputs", [])
return VisionRecipeResponse(
task_id=task.id,
recipe_dir=task.vision_recipe_dir,
recipe=task.vision_json,
files=vision_service.list_recipe_files(task.vision_recipe_dir),
outputs=outputs,
)
# Composing a recipe and keeping a reference image are Maker operations, not
# measuring: every endpoint below gates on `require_maker`, not the
# `require_measurement_tec` that `execute` above uses.
@router.post("/reference-images")
async def add_reference_image(
task_id: int = Form(...),
image: UploadFile = File(...),
note: str | None = Form(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
task = await _task_or_404(db, task_id)
content = await _read_image(image)
reference = await vision_service.save_reference_image(
db, task.id, content, note=note,
extension=_IMAGE_EXTENSIONS[image.content_type],
)
try:
await db.commit()
except Exception:
# The row never became durable - the file `save_reference_image`
# wrote after its flush would otherwise outlive it as an orphan.
vision_service.remove_reference_file(reference)
raise
return {"id": reference.id, "path": reference.path, "note": reference.note}
@router.get("/reference-images", response_model=list[VisionReferenceImageResponse])
async def get_reference_images(
task_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> list[VisionReferenceImageResponse]:
await _task_or_404(db, task_id)
return await vision_service.list_reference_images(db, task_id)
@router.delete("/reference-images/{reference_id}", status_code=204)
async def delete_reference_image(
reference_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> None:
reference = await vision_service.delete_reference_image(db, reference_id)
await db.commit()
# Only now: if the commit above had failed, the row would still exist and
# the file must still be there for it. And only if no other row (a
# copy-on-write sibling, #38.6) still references the same path.
if not await vision_service.file_still_referenced(db, reference.path):
vision_service.remove_reference_file(reference)
@router.post("/preview")
async def preview(
task_id: int = Form(...),
image: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
task = await _task_or_404(db, task_id)
return await vision_service.preview(db, task, await _read_image(image))
@router.post("/dxf-preview")
async def dxf_preview(
dxf: UploadFile = File(...),
current_user: User = Depends(require_maker),
) -> dict:
"""#101: anteprima del DXF scelto nel wizard «Crea ricetta da DXF» (contorni
in mm e dimensioni), letto dal worker con lo stesso lettore del confronto.
Non scrive nulla; stesso limite di dimensione della rotta dxf-compare."""
dxf_bytes = await dxf.read()
if not validate_file_size(len(dxf_bytes)):
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
return await vision_service.dxf_preview(dxf_bytes)
@router.post("/tasks/{task_id}/recipe/dxf-compare")
async def create_dxf_compare_recipe(
task_id: int,
image: UploadFile = File(...),
dxf: UploadFile = File(...),
station_id: int | None = Form(None, gt=0),
px_per_mm: float | None = Form(None, gt=0),
band_mm: float = Form(0.1, gt=0),
min_points_pct: float = Form(95, ge=0, le=100),
polarity: Literal["dark", "light"] = Form("dark"),
save: bool = Form(False),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
"""Ricetta DXF compare da un'immagine e un DXF, senza Studio (#83).
La scala e' `px_per_mm`, o quella della stazione (letta dall'acquisitore).
Con save=false si vede il risultato e non si scrive nulla; con save=true la
ricetta va sul task come con l'upload, e se il task non ha nessuna quota di
visione nasce la quota «Punti in tolleranza».
"""
task = await _task_or_404(db, task_id)
if task.task_type not in vision_service.VISION_TASK_TYPES:
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
station = None
if station_id is not None:
station = await station_service.get_station(db, station_id) # 404 se non esiste
if px_per_mm is None:
calibration, reason = await vision_service.fetch_station_calibration(station)
if calibration is None:
# irraggiungibile e non calibrata sono due guasti diversi per chi lo legge
raise _422([
"acquisitore della stazione non raggiungibile"
if reason == vision_service.CAL_UNREACHABLE else "stazione non calibrata"
])
px_per_mm = float(calibration["px_per_mm"])
image_bytes = await _read_image(image)
dxf_bytes = await dxf.read()
if not validate_file_size(len(dxf_bytes)):
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
body = await vision_service.dxf_compare_recipe(
image_bytes, dxf_bytes,
px_per_mm=px_per_mm, band_mm=band_mm, min_points_pct=min_points_pct,
polarity=polarity,
worker_url=(station.vision_worker_url or None) if station is not None else None,
)
# i file stanno nella ricetta salvata, non nella risposta al client
files = body.pop("files")
response = {"result": body, "saved": False, "recipe_dir": None, "outputs": None}
if not save:
return response
uploads: list[tuple[str, bytes]] = []
for name, encoded in files.items():
try:
uploads.append((name, base64.b64decode(encoded, validate=True)))
except (binascii.Error, ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker's answer was malformed: file {name!r} is not base64",
)
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
if errors:
raise _422(errors)
# #105: l'immagine di riferimento resta nella cartella (non e' in meta, quindi
# /validate e l'esecuzione la ignorano) e il wizard puo' riaprirla; entra nel digest.
placed[f"riferimento.{_IMAGE_EXTENSIONS.get(image.content_type, 'png')}"] = image_bytes
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
await vision_service.ensure_points_quote(db, task, min_points_pct)
await db.commit()
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
return response
@router.get("/tasks/{task_id}/recipe/dxf-compare/source")
async def get_dxf_compare_source(
task_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
"""#105: i parametri della ricetta fatta con «Crea ricetta da DXF», per modificarla.
404 se il task non ha una ricetta del wizard (nessuna, o caricata a mano).
"""
task = await _task_or_404(db, task_id)
source = vision_service.dxf_compare_source(task.vision_json, task.vision_recipe_dir)
if source is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="nessuna ricetta da DXF su questo task",
)
return source
@router.get("/tasks/{task_id}/recipe/files/{path:path}")
async def get_recipe_file(
task_id: int,
path: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> FileResponse:
"""#105: un file della cartella ricetta del task (DXF, immagine di riferimento).
Percorso non sicuro, file assente o task senza ricetta: sempre 404.
"""
task = await _task_or_404(db, task_id)
target = vision_service.recipe_file_path(task.vision_recipe_dir, path)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
return FileResponse(target, media_type=media_type)
+79 -4
View File
@@ -1,23 +1,58 @@
"""TieMeasureFlow Server Configuration.""" """TieMeasureFlow Server Configuration."""
from pathlib import Path from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
# Committed defaults. Fine for dev/test (SQLite in-memory, nothing exposed); a
# real deployment that still has these is running on secrets anyone can read
# in this file's git history.
_DEFAULT_SECRET_KEY = "change-this-to-a-random-secret-key"
_DEFAULT_DB_PASSWORD = "change_me_in_production"
class Settings(BaseSettings): class Settings(BaseSettings):
"""Application settings loaded from environment variables.""" """Application settings loaded from environment variables."""
# Deployment environment. "dev" and "test" skip the production-only guard
# below; anything else (e.g. "production") is treated as a real
# deployment. Local dev and the test suite must keep starting with no
# .env at all, which is why this defaults to "dev" rather than requiring
# the variable.
app_env: str = "dev"
# Database # Database
db_host: str = "localhost" db_host: str = "localhost"
db_port: int = 3306 db_port: int = 3306
db_name: str = "tiemeasureflow" db_name: str = "tiemeasureflow"
db_user: str = "tmflow" db_user: str = "tmflow"
db_password: str = "change_me_in_production" db_password: str = _DEFAULT_DB_PASSWORD
# Full SQLAlchemy async URL (env DB_URL). When set it wins over the
# DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD parts. Used by the Windows
# package: sqlite+aiosqlite:///C:/ProgramData/.../tmf.db (forward slashes).
db_url: str | None = None
# Presentation time zone (DB is UTC, #42)
display_timezone: str = "Europe/Rome"
# Server # Server
server_host: str = "0.0.0.0" server_host: str = "0.0.0.0"
server_port: int = 8000 server_port: int = 8000
server_secret_key: str = "change-this-to-a-random-secret-key" server_secret_key: str = _DEFAULT_SECRET_KEY
server_cors_origins: str = "http://localhost:5000" server_cors_origins: str = "http://localhost:5000"
# Worker processes (e.g. `uvicorn --workers N`). Each rate-limit bucket lives
# in process memory, so this is how RateLimitMiddleware divides the
# configured limits to keep the cluster-wide effective limit close to what
# was configured instead of multiplying it by N.
web_concurrency: int = 1
# API key session lifetime (hours); renewed on use (env API_KEY_TTL_HOURS).
api_key_ttl_hours: int = 24
# How many reverse proxies between the client and this API are trusted to
# have appended their own hop to X-Forwarded-For (Nginx, in the default
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
# parsing entirely (only request.client.host is trusted).
trusted_proxy_count: int = 1
# File Storage # File Storage
upload_dir: str = "uploads" upload_dir: str = "uploads"
@@ -33,14 +68,23 @@ class Settings(BaseSettings):
# Setup page (empty = disabled) # Setup page (empty = disabled)
setup_password: str | None = None setup_password: str | None = None
# By default, once an admin user exists, setup's privileged endpoints
# (reset-db, seed, manage-user, change-user-password, init-db) refuse to
# run again. Set true for scripted re-provisioning of a demo/test deploy.
setup_allow_after_init: bool = False
# AI / OpenRouter (for technical sheet parsing) # AI / OpenRouter (for technical sheet parsing)
openrouter_api_key: str | None = None openrouter_api_key: str | None = None
openrouter_model: str = "anthropic/claude-sonnet-4" openrouter_model: str = "anthropic/claude-sonnet-4"
# Vision worker (internal network only, reachable as `vision` in Compose)
vision_worker_url: str = "http://vision:8100"
@property @property
def database_url(self) -> str: def database_url(self) -> str:
"""Async MySQL connection string.""" """Async connection string: DB_URL if set, else the MySQL one."""
if self.db_url:
return self.db_url
return ( return (
f"mysql+asyncmy://{self.db_user}:{self.db_password}" f"mysql+asyncmy://{self.db_user}:{self.db_password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}" f"@{self.db_host}:{self.db_port}/{self.db_name}"
@@ -59,7 +103,12 @@ class Settings(BaseSettings):
(mounted as a Docker volume), not inside the backend tree. (mounted as a Docker volume), not inside the backend tree.
""" """
# Path(__file__) = src/backend/config.py → parents[2] = project root # Path(__file__) = src/backend/config.py → parents[2] = project root
return Path(__file__).resolve().parents[2] / self.upload_dir # UPLOAD_DIR may be absolute (Windows package: C:/ProgramData/...):
# in that case it is used as is instead of being joined to the root.
configured = Path(self.upload_dir)
if configured.is_absolute():
return configured
return Path(__file__).resolve().parents[2] / configured
# Always resolve .env against the project root regardless of cwd # Always resolve .env against the project root regardless of cwd
# (pydantic-settings would otherwise treat the path as cwd-relative). # (pydantic-settings would otherwise treat the path as cwd-relative).
@@ -69,5 +118,31 @@ class Settings(BaseSettings):
"extra": "ignore", "extra": "ignore",
} }
@model_validator(mode="after")
def _refuse_default_secrets_outside_dev(self) -> "Settings":
"""Refuse to boot with a committed default secret in a real deployment.
Only fires when APP_ENV names an actual deployment (not "dev"/"test"),
so local development and the test suite keep starting with no .env at
all.
"""
if self.app_env in ("dev", "test"):
return self
if self.server_secret_key == _DEFAULT_SECRET_KEY:
raise RuntimeError(
"SERVER_SECRET_KEY is still the committed default value; "
"set it before running with APP_ENV=" + self.app_env
)
# A SQLite file has no DB password: the default-password guard only
# makes sense for the MySQL connection built from the DB_* parts.
if self.db_url and self.db_url.startswith("sqlite"):
return self
if self.db_password == _DEFAULT_DB_PASSWORD:
raise RuntimeError(
"DB_PASSWORD is still the committed default value; "
"set it before running with APP_ENV=" + self.app_env
)
return self
settings = Settings() settings = Settings()
+25 -8
View File
@@ -1,6 +1,7 @@
"""Async SQLAlchemy database engine and session management.""" """Async SQLAlchemy database engine and session management."""
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
AsyncSession, AsyncSession,
async_sessionmaker, async_sessionmaker,
@@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase
from src.backend.config import settings from src.backend.config import settings
# Create async engine _IS_SQLITE = settings.database_url.startswith("sqlite")
engine = create_async_engine(
settings.database_url, # Create async engine. MySQL gets a connection pool; SQLite (local file of the
echo=False, # Windows package) does not accept pool_size/max_overflow/pool_recycle.
pool_size=10, if _IS_SQLITE:
max_overflow=20, engine = create_async_engine(settings.database_url, echo=False)
pool_recycle=3600,
) @event.listens_for(engine.sync_engine, "connect")
def _sqlite_pragmas(dbapi_connection, _connection_record) -> None:
"""Per-connection PRAGMAs: SQLite ignores FKs unless asked, and WAL +
busy_timeout let the web, vision and backend threads share the file."""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
else:
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_recycle=3600,
)
# Session factory # Session factory
async_session_factory = async_sessionmaker( async_session_factory = async_sessionmaker(
+6
View File
@@ -21,6 +21,9 @@ from src.backend.api.routers.reports import router as reports_router
from src.backend.api.routers.statistics import router as statistics_router from src.backend.api.routers.statistics import router as statistics_router
from src.backend.api.routers.setup import router as setup_router from src.backend.api.routers.setup import router as setup_router
from src.backend.api.routers.stations import router as stations_router from src.backend.api.routers.stations import router as stations_router
from src.backend.api.routers.production import router as production_router
from src.backend.api.routers.vision import router as vision_router
from src.backend.api.routers.task_results import router as task_results_router
@asynccontextmanager @asynccontextmanager
@@ -73,6 +76,9 @@ app.include_router(statistics_router)
app.include_router(reports_router) app.include_router(reports_router)
app.include_router(setup_router) app.include_router(setup_router)
app.include_router(stations_router) app.include_router(stations_router)
app.include_router(production_router)
app.include_router(vision_router)
app.include_router(task_results_router)
@app.get("/api/health") @app.get("/api/health")
+2
View File
@@ -36,6 +36,8 @@ from src.backend.models.orm.task import RecipeTask, RecipeSubtask # noqa: F401
from src.backend.models.orm.measurement import Measurement # noqa: F401 from src.backend.models.orm.measurement import Measurement # noqa: F401
from src.backend.models.orm.access_log import AccessLog # noqa: F401 from src.backend.models.orm.access_log import AccessLog # noqa: F401
from src.backend.models.orm.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,251 @@
"""base schema: state of the database immediately before migration 001
Every database that has run this project since before Alembic was wired in
was born from `Base.metadata.create_all()` (via `/api/setup/init-db`, or
`init_db()` in dev) - never from a migration chain, because there wasn't one.
`upgrade head` on a genuinely empty database therefore had no starting point:
001 is the oldest migration, and it only knows how to ADD columns to tables
that 001-013 all assume already exist.
This migration supplies that starting point, but only when there is nothing
there yet: on a database that already has `recipes` (the table 001 was
written against), it is a no-op, because that database was already born from
`create_all()` and already has this exact schema.
Written explicitly with `create_table` rather than `Base.metadata.create_all`:
`create_all` builds the CURRENT model definitions, which already carry every
column 001-013 add, and 001 would then try to add a column that is already
there (`Duplicate column`).
Revision ID: 000_base
Revises:
Create Date: 2026-09-21
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect as sa_inspect
# revision identifiers, used by Alembic.
revision: str = '000_base'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Table creation order matters: each one only references tables created
# before it (users -> recipes -> recipe_versions -> recipe_tasks ->
# recipe_subtasks -> measurements -> access_logs / system_settings /
# recipe_version_audit). Downgrade drops them in the opposite order.
_TABLES_NEWEST_FIRST = (
'recipe_version_audit', 'system_settings', 'access_logs', 'measurements',
'recipe_subtasks', 'recipe_tasks', 'recipe_versions', 'recipes', 'users',
)
def upgrade() -> None:
bind = op.get_bind()
if sa_inspect(bind).has_table('recipes'):
# Already created (by create_all, or by a previous run of this same
# migration) - nothing to do.
return
op.create_table(
'users',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('username', sa.String(100), nullable=False),
sa.Column('password_hash', sa.String(255), nullable=False),
sa.Column('email', sa.String(255), nullable=True),
sa.Column('display_name', sa.String(255), nullable=False),
sa.Column('roles', sa.JSON, nullable=False),
sa.Column('is_admin', sa.Boolean, nullable=False, server_default='0'),
sa.Column(
'language_pref', sa.Enum('it', 'en', name='language_enum'),
nullable=False, server_default='it',
),
sa.Column(
'theme_pref', sa.Enum('light', 'dark', name='theme_enum'),
nullable=False, server_default='light',
),
sa.Column('active', sa.Boolean, nullable=False, server_default='1'),
sa.Column('api_key', sa.String(64), nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('last_login', sa.DateTime, nullable=True),
sa.UniqueConstraint('username', name='uq_users_username'),
sa.UniqueConstraint('api_key', name='uq_users_api_key'),
sa.Index('ix_users_username', 'username'),
sa.Index('ix_users_api_key', 'api_key'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_table(
'recipes',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('code', sa.String(100), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('description', sa.Text, nullable=True),
sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('active', sa.Boolean, nullable=False, server_default='1'),
sa.UniqueConstraint('code', name='uq_recipes_code'),
sa.Index('ix_recipes_code', 'code'),
sa.Index('ix_recipes_active', 'active'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_table(
'recipe_versions',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False),
sa.Column('version_number', sa.Integer, nullable=False),
sa.Column('is_current', sa.Boolean, nullable=False, server_default='0'),
sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('change_notes', sa.Text, nullable=True),
sa.UniqueConstraint('recipe_id', 'version_number', name='uq_recipe_version'),
sa.Index('ix_recipe_version_current', 'recipe_id', 'is_current'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_table(
'recipe_tasks',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'), nullable=False),
sa.Column('order_index', sa.Integer, nullable=False, server_default='0'),
sa.Column('title', sa.String(255), nullable=False),
sa.Column('directive', sa.Text, nullable=True),
sa.Column('description', sa.Text, nullable=True),
sa.Column('file_path', sa.String(500), nullable=True),
sa.Column(
'file_type', sa.Enum('image', 'pdf', name='file_type_enum'),
nullable=True,
),
sa.Column('annotations_json', sa.JSON, nullable=True),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_table(
'recipe_subtasks',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column(
'task_id', sa.Integer,
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False,
),
sa.Column('marker_number', sa.Integer, nullable=False),
sa.Column('description', sa.String(500), nullable=False),
sa.Column('measurement_type', sa.String(100), nullable=True),
sa.Column('nominal', sa.DECIMAL(12, 6), nullable=True),
sa.Column('utl', sa.DECIMAL(12, 6), nullable=True),
sa.Column('uwl', sa.DECIMAL(12, 6), nullable=True),
sa.Column('lwl', sa.DECIMAL(12, 6), nullable=True),
sa.Column('ltl', sa.DECIMAL(12, 6), nullable=True),
sa.Column('unit', sa.String(20), nullable=False, server_default='mm'),
sa.UniqueConstraint('task_id', 'marker_number', name='uq_task_marker'),
sa.Index('ix_subtask_task_id', 'task_id'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_table(
'measurements',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column(
'subtask_id', sa.Integer, sa.ForeignKey('recipe_subtasks.id'),
nullable=False,
),
sa.Column(
'version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'),
nullable=False,
),
sa.Column(
'measured_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False,
),
sa.Column('value', sa.DECIMAL(12, 6), nullable=False),
sa.Column(
'pass_fail', sa.Enum('pass', 'warning', 'fail', name='pass_fail_enum'),
nullable=False,
),
sa.Column('deviation', sa.DECIMAL(12, 6), nullable=True),
sa.Column('lot_number', sa.String(100), nullable=True),
sa.Column('serial_number', sa.String(100), nullable=True),
sa.Column(
'input_method',
sa.Enum('usb_caliper', 'manual', name='input_method_enum'),
nullable=False, server_default='manual',
),
sa.Column(
'measured_at', sa.DateTime, nullable=False, server_default=sa.func.now(),
),
sa.Column('synced_to_csv', sa.Boolean, nullable=True, server_default='0'),
sa.Index('ix_measurements_subtask_id', 'subtask_id'),
sa.Index('ix_measurements_version_id', 'version_id'),
sa.Index('ix_measurements_measured_by', 'measured_by'),
sa.Index('ix_measurements_lot_number', 'lot_number'),
sa.Index('ix_measurements_serial_number', 'serial_number'),
sa.Index('ix_measurements_measured_at', 'measured_at'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_table(
'access_logs',
sa.Column('id', sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
sa.Column('action', sa.String(100), nullable=False),
sa.Column('details', sa.JSON, nullable=True),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('user_agent', sa.String(500), nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Index('ix_access_logs_action', 'action'),
sa.Index('ix_access_log_user_time', 'user_id', 'created_at'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_table(
'system_settings',
sa.Column('setting_key', sa.String(100), primary_key=True),
sa.Column('setting_value', sa.Text, nullable=False),
sa.Column(
'setting_type',
sa.Enum('string', 'number', 'boolean', 'json', name='setting_type_enum'),
nullable=False, server_default='string',
),
sa.Column('description', sa.String(500), nullable=True),
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('updated_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_table(
'recipe_version_audit',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False),
sa.Column('old_version_id', sa.Integer, nullable=True),
sa.Column('new_version_id', sa.Integer, nullable=False),
sa.Column('changed_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
sa.Column(
'change_type',
sa.Enum('CREATE', 'UPDATE', 'ACTIVATE', 'RETIRE', name='change_type_enum'),
nullable=False,
),
sa.Column('change_reason', sa.Text, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Index('ix_recipe_audit_recipe_time', 'recipe_id', 'created_at'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa_inspect(bind)
for table in _TABLES_NEWEST_FIRST:
if inspector.has_table(table):
op.drop_table(table)
@@ -12,7 +12,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '001_image_path' revision: str = '001_image_path'
down_revision: Union[str, None] = None down_revision: Union[str, None] = '000_base'
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@@ -0,0 +1,100 @@
"""add production_runs and production_events
Gives a production a life of its own: before this its state lived in the Alpine
component of task_execute.html, so changing task - a full page load - lost the timer,
the cycle count and the "production started" flag.
Revision ID: 005_production_runs
Revises: 004_input_duration
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '005_production_runs'
down_revision: Union[str, None] = '004_input_duration'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'production_runs',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('station_id', sa.Integer, sa.ForeignKey('stations.id'), nullable=False),
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False),
sa.Column('version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'), nullable=False),
sa.Column('operator_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
sa.Column('lot_number', sa.String(100), nullable=True),
sa.Column('serial_number', sa.String(100), nullable=True),
sa.Column(
'status',
sa.Enum('running', 'paused', 'closed', name='production_run_status_enum'),
nullable=False,
server_default='running',
),
sa.Column('measurement_interval_minutes', sa.SmallInteger, nullable=True),
sa.Column('next_measurement_at', sa.DateTime, nullable=True),
sa.Column('cycle_count', sa.Integer, nullable=False, server_default='0'),
sa.Column('started_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('paused_at', sa.DateTime, nullable=True),
sa.Column('closed_at', sa.DateTime, nullable=True),
sa.Column('closed_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
# Mirrors station_id while open, NULL once closed. The unique constraint makes
# "one open run per station" a database guarantee rather than a race; repeated
# NULLs do not collide, so closed runs are free to pile up.
sa.Column('active_station_id', sa.Integer, nullable=True),
# Declared inline rather than added afterwards: adding a constraint is an ALTER,
# which SQLite cannot do, and the test databases are SQLite.
sa.UniqueConstraint('active_station_id', name='uq_production_runs_active_station'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_index('ix_production_runs_station_id', 'production_runs', ['station_id'])
op.create_index('ix_production_runs_recipe_id', 'production_runs', ['recipe_id'])
op.create_index('ix_production_runs_version_id', 'production_runs', ['version_id'])
op.create_index('ix_production_runs_operator_id', 'production_runs', ['operator_id'])
op.create_index('ix_production_runs_lot_number', 'production_runs', ['lot_number'])
op.create_index('ix_production_runs_serial_number', 'production_runs', ['serial_number'])
op.create_index('ix_production_runs_status', 'production_runs', ['status'])
op.create_index(
'ix_production_runs_next_measurement_at', 'production_runs', ['next_measurement_at'],
)
op.create_index(
'ix_production_runs_station_status', 'production_runs', ['station_id', 'status'],
)
op.create_table(
'production_events',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column(
'run_id', sa.Integer,
sa.ForeignKey('production_runs.id', ondelete='CASCADE'), nullable=False,
),
sa.Column(
'event_type',
sa.Enum(
'start', 'cycle_completed', 'line_stop', 'resume', 'close',
name='production_event_type_enum',
),
nullable=False,
),
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
sa.Column('supervisor_id', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
sa.Column('note', sa.Text, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_index('ix_production_events_run_id', 'production_events', ['run_id'])
op.create_index('ix_production_events_event_type', 'production_events', ['event_type'])
op.create_index('ix_production_events_user_id', 'production_events', ['user_id'])
op.create_index('ix_production_events_created_at', 'production_events', ['created_at'])
def downgrade() -> None:
op.drop_table('production_events')
op.drop_table('production_runs')
@@ -0,0 +1,63 @@
"""link measurements to their production run
Fine produzione has to hand the measurements of the whole production to the
statistics file. Without this column "the measurements of this production" is not a
query: measurements only knew their recipe version, lot and serial, none of which
delimits one run from the next on the same recipe and lot.
Nullable on purpose: measurements taken before this - and any taken outside a
production - simply have no run.
Revision ID: 006_measurement_run
Revises: 005_production_runs
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '006_measurement_run'
down_revision: Union[str, None] = '005_production_runs'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Batch mode, because the column carries a foreign key: adding a constraint is
# an ALTER that SQLite cannot do, and alembic raises rather than silently
# dropping it. On MySQL this is a plain ALTER; on SQLite it rebuilds the table.
# Keeping the key is worth the ceremony - ON DELETE SET NULL means a measurement,
# the record that matters in an audit, survives its run being deleted.
with op.batch_alter_table('measurements') as batch_op:
batch_op.add_column(
sa.Column(
'production_run_id', sa.Integer,
sa.ForeignKey(
'production_runs.id',
ondelete='SET NULL',
# Named because batch mode requires it, and because an anonymous
# constraint cannot be referred to later.
name='fk_measurements_production_run',
),
nullable=True,
),
)
op.create_index(
'ix_measurements_production_run_id', 'measurements', ['production_run_id'],
)
# Where the statistics file for a closed run was written. No constraint, so a
# plain ALTER is enough.
op.add_column(
'production_runs',
sa.Column('statistics_path', sa.String(500), nullable=True),
)
def downgrade() -> None:
op.drop_column('production_runs', 'statistics_path')
op.drop_index('ix_measurements_production_run_id', table_name='measurements')
with op.batch_alter_table('measurements') as batch_op:
batch_op.drop_column('production_run_id')
@@ -0,0 +1,64 @@
"""add an explicit type to recipe tasks
Until now the kind of a task was deduced: quotes present meant a measurement,
otherwise a note. A measurement task whose quotes had not been entered yet was
therefore treated as a note - the system behaved differently depending on how
complete the recipe happened to be.
Existing rows are classified by the rule that was in force, so nothing changes
behaviour on upgrade: tasks with quotes become 'measure'. Tasks with no quotes but
a drawing attached become 'drawing' rather than 'note' - that is what they already
displayed as, and calling them notes would be the one place this migration did
change behaviour.
Revision ID: 007_task_type
Revises: 006_measurement_run
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '007_task_type'
down_revision: Union[str, None] = '006_measurement_run'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TASK_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
def upgrade() -> None:
op.add_column(
'recipe_tasks',
sa.Column(
'task_type',
sa.Enum(*TASK_TYPES, name='task_type_enum'),
nullable=False,
server_default='note',
),
)
op.create_index('ix_recipe_tasks_task_type', 'recipe_tasks', ['task_type'])
# Backfill by the rule the frontend used, so no recipe changes behaviour.
op.execute(
"""
UPDATE recipe_tasks
SET task_type = 'measure'
WHERE id IN (SELECT DISTINCT task_id FROM recipe_subtasks)
"""
)
op.execute(
"""
UPDATE recipe_tasks
SET task_type = 'drawing'
WHERE task_type = 'note'
AND file_path IS NOT NULL
"""
)
def downgrade() -> None:
op.drop_index('ix_recipe_tasks_task_type', table_name='recipe_tasks')
op.drop_column('recipe_tasks', 'task_type')
@@ -0,0 +1,74 @@
"""widen the production event trace for the measurement loop
The loop needs to distinguish two things the trace could not say before: a
measurement task finished while the cycle is still running (task_measured), and the
piece turned over and measured again without closing the cycle (remeasure). Only
the last measurement task of a recipe still produces cycle_completed, because only
that one restarts the interval.
Both values are added in one go. Widening a MySQL enum rewrites the table, so the
values that are already planned go in now rather than one migration at a time -
the same reasoning as the task types in 007.
The events that happen inside a cycle also need to say which task they are about,
so the column comes with them: a task_measured that does not name its task records
nothing usable.
Revision ID: 008_loop_events
Revises: 007_task_type
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '008_loop_events'
down_revision: Union[str, None] = '007_task_type'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_TYPES = ('start', 'cycle_completed', 'line_stop', 'resume', 'close')
NEW_TYPES = (
'start', 'cycle_completed', 'task_measured', 'remeasure',
'line_stop', 'resume', 'close',
)
def _alter(values: Sequence[str]) -> None:
"""Restate the column with a new set of allowed values.
On MySQL this is the ALTER that rewrites the enum. On SQLite, where an enum is
stored as a plain VARCHAR, batch mode rebuilds the table instead of emitting an
ALTER the engine does not support - the tests run there.
"""
with op.batch_alter_table('production_events') as batch:
batch.alter_column(
'event_type',
existing_type=sa.Enum(*OLD_TYPES, name='production_event_type_enum'),
type_=sa.Enum(*values, name='production_event_type_enum'),
existing_nullable=False,
)
def upgrade() -> None:
_alter(NEW_TYPES)
with op.batch_alter_table('production_events') as batch:
batch.add_column(sa.Column('task_id', sa.Integer(), nullable=True))
batch.create_foreign_key(
'fk_production_events_task_id', 'recipe_tasks', ['task_id'], ['id'],
)
def downgrade() -> None:
with op.batch_alter_table('production_events') as batch:
batch.drop_constraint('fk_production_events_task_id', type_='foreignkey')
batch.drop_column('task_id')
# The new kinds of event have no equivalent in the old vocabulary, and a row
# left with a value the column no longer accepts would fail the rebuild.
op.execute(
"DELETE FROM production_events "
"WHERE event_type IN ('task_measured', 'remeasure')"
)
_alter(OLD_TYPES)
@@ -0,0 +1,57 @@
"""traceability and manual input become rules of the recipe
Three settings that were nowhere: whether the lot and the serial are compulsory,
and whether a value may be typed instead of read from the caliper. Until now lot
and serial were optional everywhere and the keypad was always available, so a
value that happens to be in tolerance could simply be entered by hand.
Backfill: the two traceability flags start false, which is exactly today's
behaviour. Manual input starts *true* on the recipes that already exist - the
column default is false, so recipes written from now on are caliper-only, but
flipping the ones already in use would stop a running line at the next
measurement. Turning them off is a decision for whoever owns the recipe, taken in
the editor, not a side effect of an upgrade.
Revision ID: 009_recipe_rules
Revises: 008_loop_events
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '009_recipe_rules'
down_revision: Union[str, None] = '008_loop_events'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'recipes',
sa.Column(
'requires_lot', sa.Boolean(), nullable=False, server_default='0',
),
)
op.add_column(
'recipes',
sa.Column(
'requires_serial', sa.Boolean(), nullable=False, server_default='0',
),
)
op.add_column(
'recipes',
sa.Column(
'allow_manual_input', sa.Boolean(), nullable=False, server_default='0',
),
)
# Recipes already in production keep the behaviour they were written under.
op.execute("UPDATE recipes SET allow_manual_input = 1")
def downgrade() -> None:
op.drop_column('recipes', 'allow_manual_input')
op.drop_column('recipes', 'requires_serial')
op.drop_column('recipes', 'requires_lot')
@@ -0,0 +1,49 @@
"""record who authorised an out-of-tolerance measurement
The supervisor gate existed on screen and nowhere else: the modal opened, the
credentials were checked, the modal closed, and nothing was written down. Nothing
depended on the answer either, so a value outside tolerance could be left behind
by dismissing the modal.
These two columns are what the rule now stands on. A failed measurement with no
supervisor on it is a measurement waiting for one, and while one is waiting the
operator cannot move to the next quote.
Existing rows are left null. Backfilling an authorisation that never happened
would be inventing an audit record; the fails already in the database belong to
production runs that are over, and nothing is waiting on them.
Revision ID: 010_meas_authorisation
Revises: 009_recipe_rules
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '010_meas_authorisation'
down_revision: Union[str, None] = '009_recipe_rules'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.add_column(sa.Column('supervisor_id', sa.Integer(), nullable=True))
batch.add_column(sa.Column('authorised_at', sa.DateTime(), nullable=True))
batch.create_foreign_key(
'fk_measurements_supervisor_id', 'users', ['supervisor_id'], ['id'],
)
batch.create_index(
'ix_measurements_supervisor_id', ['supervisor_id'],
)
def downgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.drop_index('ix_measurements_supervisor_id')
batch.drop_constraint('fk_measurements_supervisor_id', type_='foreignkey')
batch.drop_column('authorised_at')
batch.drop_column('supervisor_id')
@@ -0,0 +1,55 @@
"""the vision graph lives on the task, and the quote names its output
`xf_compare` was a typo for `dxf_compare`. No row uses either value yet, so
renaming costs one statement here; from the first saved task onwards it would
be a data migration and the typo would already be in the public API.
Revision ID: 011_vision_graph
Revises: 010_meas_authorisation
Create Date: 2026-08-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '011_vision_graph'
down_revision: Union[str, None] = '010_meas_authorisation'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
NEW_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
def upgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('vision_json', sa.JSON(), nullable=True))
batch.alter_column(
'task_type',
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
with op.batch_alter_table('recipe_subtasks') as batch:
batch.add_column(
sa.Column('vision_output', sa.String(length=120), nullable=True)
)
def downgrade() -> None:
with op.batch_alter_table('recipe_subtasks') as batch:
batch.drop_column('vision_output')
with op.batch_alter_table('recipe_tasks') as batch:
batch.alter_column(
'task_type',
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
batch.drop_column('vision_json')
@@ -0,0 +1,79 @@
"""one row per vision execution, and camera as an input method
The vision surroundings do not go on `measurements`: statistics and the export
read that table on every pass. And it would be wrong modelling anyway - one
acquisition produces N quotes, and image, overlay, device and engine version are
the same for all of them.
Revision ID: 012_vision_results
Revises: 011_vision_graph
Create Date: 2026-08-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '012_vision_results'
down_revision: Union[str, None] = '011_vision_graph'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_METHODS = ('usb_caliper', 'manual')
NEW_METHODS = ('usb_caliper', 'manual', 'camera')
def upgrade() -> None:
op.create_table(
'vision_results',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('task_id', sa.Integer(),
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
sa.Column('image_path', sa.String(length=500), nullable=True),
sa.Column('overlay_path', sa.String(length=500), nullable=True),
sa.Column('engine_version', sa.String(length=64), nullable=False),
sa.Column('executed_on',
sa.Enum('server', 'station', name='vision_executed_on_enum'),
nullable=False),
sa.Column('station_id', sa.Integer(),
sa.ForeignKey('stations.id'), nullable=True),
sa.Column('device_code', sa.String(length=100), nullable=True),
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
sa.Column('graph_snapshot', sa.JSON(), nullable=False),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('executed_at', sa.DateTime(), nullable=False,
server_default=sa.func.now()),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
with op.batch_alter_table('measurements') as batch:
batch.alter_column(
'input_method',
existing_type=sa.Enum(*OLD_METHODS, name='input_method_enum'),
type_=sa.Enum(*NEW_METHODS, name='input_method_enum'),
existing_nullable=False,
)
batch.add_column(
sa.Column('vision_result_id', sa.Integer(), nullable=True)
)
batch.create_foreign_key(
'fk_measurements_vision_result',
'vision_results', ['vision_result_id'], ['id'],
)
def downgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.drop_constraint('fk_measurements_vision_result',
type_='foreignkey')
batch.drop_column('vision_result_id')
batch.alter_column(
'input_method',
existing_type=sa.Enum(*NEW_METHODS, name='input_method_enum'),
type_=sa.Enum(*OLD_METHODS, name='input_method_enum'),
existing_nullable=False,
)
op.drop_table('vision_results')
@@ -0,0 +1,49 @@
"""reference images, with their provenance
A reference image without provenance is a trap: the graph is re-run months later,
different numbers come out, and there is no way to tell an updated engine from a
moved lens.
`expected_json` holds what the graph is supposed to produce on this image. It is
what makes a regression run possible after a VisionSuite upgrade - the safety net
that makes bumping the submodule sustainable with an engine on two hosts.
Revision ID: 013_reference_images
Revises: 012_vision_results
Create Date: 2026-08-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '013_reference_images'
down_revision: Union[str, None] = '012_vision_results'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'vision_reference_images',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('task_id', sa.Integer(),
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
sa.Column('path', sa.String(length=500), nullable=False),
sa.Column('station_id', sa.Integer(),
sa.ForeignKey('stations.id'), nullable=True),
sa.Column('device_code', sa.String(length=100), nullable=True),
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
sa.Column('engine_version', sa.String(length=64), nullable=True),
sa.Column('note', sa.String(length=500), nullable=True),
sa.Column('expected_json', sa.JSON(), nullable=True),
sa.Column('acquired_at', sa.DateTime(), nullable=False,
server_default=sa.func.now()),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
def downgrade() -> None:
op.drop_table('vision_reference_images')
@@ -0,0 +1,76 @@
"""vision results and reference images cascade with their task
Both `vision_results.task_id` and `vision_reference_images.task_id` were
created (012, 013) with a plain, unnamed foreign key - no ON DELETE clause,
so deleting a task with any camera history or reference image attached fails
on a strict FK check (MySQL) instead of taking the vision rows with it, the
way `production_events.run_id` already does for a closed production run.
The original constraint has no name to drop by (SQLAlchemy leaves inline
`ForeignKey(...)` unnamed unless told otherwise, and MySQL then assigns its
own `<table>_ibfk_N`, which is not portable to reflect on). `sa.inspect`
looks it up at migration time instead of guessing it - the same way on
MySQL and on SQLite (batch mode), where an anonymous FK reflects with
`name=None` and there is nothing to drop before adding the new one.
Reference images on disk are not touched here - deleting a task's row does
not, on its own, know to also delete the file next to it; that gap is
declared open in issue #38 point 6's write-up (`vision_service` has no
single point today that intercepts a task deletion).
Revision ID: 014_vision_fk_ondelete
Revises: 013_reference_images
Create Date: 2026-09-21
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '014_vision_fk_ondelete'
down_revision: Union[str, None] = '013_reference_images'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NEW_FK_NAMES = {
'vision_results': 'fk_vision_results_task_id',
'vision_reference_images': 'fk_vision_reference_images_task_id',
}
def _existing_task_id_fk_name(bind, table_name: str) -> Union[str, None]:
"""The name reflection gives the pre-existing `task_id` FK, if any.
None on SQLite for a constraint that was never named - batch mode then
has nothing to drop, and just adds the new named one alongside it.
"""
inspector = sa.inspect(bind)
for fk in inspector.get_foreign_keys(table_name):
if fk.get("constrained_columns") == ["task_id"]:
return fk.get("name")
return None
def upgrade() -> None:
bind = op.get_bind()
for table_name, new_name in _NEW_FK_NAMES.items():
existing_name = _existing_task_id_fk_name(bind, table_name)
with op.batch_alter_table(table_name) as batch:
if existing_name:
batch.drop_constraint(existing_name, type_='foreignkey')
batch.create_foreign_key(
new_name, 'recipe_tasks', ['task_id'], ['id'],
ondelete='CASCADE',
)
def downgrade() -> None:
# Reverse order: reference_images before results, mirroring upgrade().
for table_name, name in reversed(list(_NEW_FK_NAMES.items())):
with op.batch_alter_table(table_name) as batch:
batch.drop_constraint(name, type_='foreignkey')
batch.create_foreign_key(
name, 'recipe_tasks', ['task_id'], ['id'],
)
@@ -0,0 +1,74 @@
"""API keys as SHA-256 hashes, one per session (#41)
Replaces the plaintext `users.api_key` column with an `api_keys` table: one
row per login, holding only the SHA-256 hex digest, an expiry and the last
use. Existing keys are invalidated on purpose, not migrated - the plaintext
is exactly what must stop existing, so everybody logs in once again.
Revision ID: 015_api_key_hash
Revises: 014_vision_fk_ondelete
Create Date: 2026-10-06
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '015_api_key_hash'
down_revision: Union[str, None] = '014_vision_fk_ondelete'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Idempotent on purpose (#71): a database born from create_all has no
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
# created and `alembic_version` still at 014. Every step checks first.
insp = sa.inspect(op.get_bind())
if 'api_keys' not in insp.get_table_names():
op.create_table(
'api_keys',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('key_hash', sa.String(64), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
),
)
existing_idx: set[str] = set()
else:
existing_idx = {i['name'] for i in insp.get_indexes('api_keys')}
if 'ix_api_keys_key_hash' not in existing_idx:
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
if 'ix_api_keys_user_id' not in existing_idx:
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
if 'api_key' not in {c['name'] for c in insp.get_columns('users')}:
return # column already gone: nothing else to do
user_idx = {i['name'] for i in insp.get_indexes('users')}
user_uq = {u['name'] for u in insp.get_unique_constraints('users')}
with op.batch_alter_table('users') as batch:
if 'ix_users_api_key' in user_idx:
batch.drop_index('ix_users_api_key')
if 'uq_users_api_key' in user_uq:
batch.drop_constraint('uq_users_api_key', type_='unique')
batch.drop_column('api_key')
def downgrade() -> None:
with op.batch_alter_table('users') as batch:
batch.add_column(sa.Column('api_key', sa.String(64), nullable=True))
batch.create_unique_constraint('uq_users_api_key', ['api_key'])
batch.create_index('ix_users_api_key', ['api_key'])
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
op.drop_table('api_keys')
@@ -0,0 +1,30 @@
"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46)
Percorso relativo sotto upload_path della cartella immutabile che contiene
recipe.json e i file di meta (DXF, modelli .npz, ...).
Revision ID: 016_vision_recipe_dir
Revises: 015_api_key_hash
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '016_vision_recipe_dir'
down_revision: Union[str, None] = '015_api_key_hash'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.drop_column('vision_recipe_dir')
@@ -0,0 +1,34 @@
"""users: PIN a 4 cifre per login rapido e capoturno (#59)
pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco.
Revision ID: 017_user_pin
Revises: 016_vision_recipe_dir
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '017_user_pin'
down_revision: Union[str, None] = '016_vision_recipe_dir'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
with op.batch_alter_table('users') as batch:
batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True))
batch.add_column(sa.Column(
'pin_failed_count', sa.Integer(), nullable=False, server_default='0'))
batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('users') as batch:
batch.drop_column('pin_locked_until')
batch.drop_column('pin_failed_count')
batch.drop_column('pin_hash')
@@ -0,0 +1,108 @@
"""task: in_loop / at_close / fields, tipo 'check', tabella task_results (#53 #54 #55)
in_loop: il task fa parte del ciclo di produzione (prima: implicito dal tipo).
at_close: il task 'note' si compila alla fine produzione.
fields: campi extra del task 'note' (JSON).
task_results: esito registrato da un task 'check' (OK/NOK) o 'note' (done).
Revision ID: 018_task_loop_results
Revises: 017_user_pin
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '018_task_loop_results'
down_revision: Union[str, None] = '017_user_pin'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
NEW_TYPES = OLD_TYPES + ('check',)
# stessi tipi di MEASURING_TASK_TYPES: fino a oggi erano l'unico ciclo possibile
LOOP_TYPES = ('measure', 'camera_measure', 'dxf_compare')
def upgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column(
'in_loop', sa.Boolean(), nullable=False, server_default=sa.false()))
batch.add_column(sa.Column(
'at_close', sa.Boolean(), nullable=False, server_default=sa.false()))
batch.add_column(sa.Column('fields', sa.JSON(), nullable=True))
# enum allargato come in 011: 'check' = Controllo OK/NOK
batch.alter_column(
'task_type',
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
# i task di misura erano gia' nel ciclo: lo restano
recipe_tasks = sa.table(
'recipe_tasks', sa.column('task_type', sa.String), sa.column('in_loop', sa.Boolean))
op.execute(
recipe_tasks.update()
.where(recipe_tasks.c.task_type.in_(LOOP_TYPES))
.values(in_loop=True)
)
op.create_table(
'task_results',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('task_id', sa.Integer(),
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False),
sa.Column('version_id', sa.Integer(),
sa.ForeignKey('recipe_versions.id'), nullable=False),
sa.Column('production_run_id', sa.Integer(),
sa.ForeignKey('production_runs.id', ondelete='SET NULL'), nullable=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('outcome', sa.Enum('ok', 'nok', 'done', name='task_outcome_enum'),
nullable=False),
sa.Column('note', sa.Text(), nullable=True),
sa.Column('values', sa.JSON(), nullable=True),
sa.Column('attachments', sa.JSON(), 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_task_results_task_id', 'task_results', ['task_id'])
op.create_index('ix_task_results_version_id', 'task_results', ['version_id'])
op.create_index(
'ix_task_results_production_run_id', 'task_results', ['production_run_id'])
def downgrade() -> None:
# il tipo 'check' non esiste prima di 018: se un task lo usa, tornare indietro
# lo lascerebbe con un valore fuori enum
bind = op.get_bind()
used = bind.execute(
sa.text("SELECT COUNT(*) FROM recipe_tasks WHERE task_type = 'check'")
).scalar()
if used:
raise RuntimeError(
f"Downgrade 018 impossibile: {used} task usano il tipo 'check'. "
"Cambiare il tipo o eliminare quei task prima di tornare indietro."
)
op.drop_index('ix_task_results_production_run_id', table_name='task_results')
op.drop_index('ix_task_results_version_id', table_name='task_results')
op.drop_index('ix_task_results_task_id', table_name='task_results')
op.drop_table('task_results')
with op.batch_alter_table('recipe_tasks') as batch:
batch.alter_column(
'task_type',
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
batch.drop_column('fields')
batch.drop_column('at_close')
batch.drop_column('in_loop')
@@ -0,0 +1,34 @@
"""stations.vision_worker_url e vision_results.worker_url (#45)
stations.vision_worker_url: worker di visione della stazione (NULL = centrale).
vision_results.worker_url: worker effettivamente chiamato per quel risultato.
Revision ID: 019_station_vision_worker
Revises: 018_task_loop_results
Create Date: 2026-10-11
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '019_station_vision_worker'
down_revision: Union[str, None] = '018_task_loop_results'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('stations') as batch:
batch.add_column(sa.Column('vision_worker_url', sa.String(500), nullable=True))
with op.batch_alter_table('vision_results') as batch:
batch.add_column(sa.Column('worker_url', sa.String(500), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('vision_results') as batch:
batch.drop_column('worker_url')
with op.batch_alter_table('stations') as batch:
batch.drop_column('vision_worker_url')
@@ -0,0 +1,33 @@
"""stations.camera_url e stations.camera_source (#72)
stations.camera_url: servizio camera della stazione (vs-gui o equivalente).
stations.camera_source: sorgente da chiedere al servizio, es. camera:<seriale>
o camera:folder.
Revision ID: 020_station_camera
Revises: 019_station_vision_worker
Create Date: 2026-10-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '020_station_camera'
down_revision: Union[str, None] = '019_station_vision_worker'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('stations') as batch:
batch.add_column(sa.Column('camera_url', sa.String(500), nullable=True))
batch.add_column(sa.Column('camera_source', sa.String(200), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('stations') as batch:
batch.drop_column('camera_source')
batch.drop_column('camera_url')
@@ -0,0 +1,31 @@
"""recipe_tasks.acquisition_json (#97)
recipe_tasks.acquisition_json: camera e modo di analisi scelti nel task,
{"camera_url", "camera_source", "modo": "impulsiva" | "in_linea"}; NULL = la
camera della stazione dell'operatore, analisi impulsiva (come prima).
Revision ID: 021_task_acquisition
Revises: 020_station_camera
Create Date: 2026-10-24
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '021_task_acquisition'
down_revision: Union[str, None] = '020_station_camera'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('acquisition_json', sa.JSON(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.drop_column('acquisition_json')
@@ -0,0 +1,39 @@
"""measurements.value nullable (#118)
measurements.value: NULL = quota che il grafo di visione non ha prodotto
(l'operatore la vede come NaN, esito sempre `fail`, deviazione NULL). Prima
execute rifiutava con 422 l'intera acquisizione. measurements.deviation era
gia' nullable.
Downgrade: torna NOT NULL; FALLISCE se esistono righe con value NULL (vanno
cancellate o valorizzate prima).
Revision ID: 022_measurement_value_nullable
Revises: 021_task_acquisition
Create Date: 2026-10-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '022_measurement_value_nullable'
down_revision: Union[str, None] = '021_task_acquisition'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('measurements') as batch:
batch.alter_column(
'value', existing_type=sa.DECIMAL(12, 6), nullable=True,
)
def downgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.alter_column(
'value', existing_type=sa.DECIMAL(12, 6), nullable=False,
)
+76 -6
View File
@@ -1,19 +1,33 @@
"""Pydantic schemas for Measurement operations.""" """Pydantic schemas for Measurement operations."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, model_validator
class MeasurementCreate(BaseModel): class MeasurementCreate(BaseModel):
"""Schema for creating a single measurement.""" """Schema for creating a single measurement."""
subtask_id: int subtask_id: int
version_id: int version_id: int
# Finiteness and the DECIMAL(12,6) scale bound are enforced in
# measurement_service.save_measurement, not with a Field constraint here:
# a NaN/Infinity that fails a pydantic Field constraint gets echoed back
# in the validation error's "input", and FastAPI's default error response
# then crashes with a 500 trying to JSON-encode that same NaN/Infinity
# (json.dumps(allow_nan=False) in Starlette's JSONResponse). A plain
# HTTPException raised after parsing sidesteps that.
value: float value: float
lot_number: Optional[str] = Field(None, max_length=100) lot_number: Optional[str] = Field(None, max_length=100)
serial_number: Optional[str] = Field(None, max_length=100) serial_number: Optional[str] = Field(None, max_length=100)
# "camera" is deliberately absent from this pattern, even though the ORM
# enum and the database allow it. Do not add it here: a camera
# measurement must be produced by vision_service.execute_task, never by a
# client posting JSON straight to this endpoint - there is no image, no
# graph and no engine_version behind a value that arrived this way.
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$") input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
input_duration_ms: Optional[int] = Field(None, ge=0) 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):
@@ -29,14 +43,19 @@ class MeasurementResponse(BaseModel):
subtask_id: int subtask_id: int
version_id: int version_id: int
measured_by: int measured_by: int
value: float value: Optional[float] = None # #118: null = quota non prodotta dalla visione (NaN)
pass_fail: str pass_fail: str
deviation: Optional[float] = None deviation: Optional[float] = None
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 input_duration_ms: Optional[int] = None
measured_at: datetime production_run_id: Optional[int] = None
# Who let this value stand, when it was out of tolerance. Null on a value in
# tolerance - and on one that is not and is still waiting for an answer.
supervisor_id: Optional[int] = None
authorised_at: Optional[UtcDateTime] = None
measured_at: UtcDateTime
synced_to_csv: bool synced_to_csv: bool
@@ -49,6 +68,57 @@ class MeasurementListResponse(BaseModel):
pages: int pages: int
class MeasurementAuthorisation(BaseModel):
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
supervisor_username: Optional[str] = Field(None, min_length=1)
supervisor_password: Optional[str] = Field(None, min_length=1)
supervisor_user_id: Optional[int] = Field(None, gt=0)
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
@model_validator(mode="after")
def _one_credential_pair(self):
by_password = self.supervisor_username and self.supervisor_password
by_pin = self.supervisor_user_id and self.supervisor_pin
if not (by_password or by_pin):
raise ValueError(
"supervisor credentials required: username+password or user_id+pin"
)
return self
class TaskProgressResponse(BaseModel):
"""How far one measurement task has got: quotes taken out of quotes expected."""
task_id: int
quotes: int
measured: int
# empty (declared as measurement, no quotes yet) | none | partial | complete
state: str
class TaskProgressListResponse(BaseModel):
"""Progress of every measurement task of a version."""
tasks: list[TaskProgressResponse]
class CycleMeasurementResponse(BaseModel):
"""One quote already measured in the cycle under way."""
subtask_id: int
value: Optional[float] = None # #118: null = NaN
pass_fail: str
measured_at: UtcDateTime
class CurrentCycleResponse(BaseModel):
"""The quotes to restore on the screen after a reload; empty if the task is closed."""
measurements: list[CycleMeasurementResponse]
class MeasurementQuery(BaseModel): class MeasurementQuery(BaseModel):
"""Schema for measurement query filters.""" """Schema for measurement query filters."""
recipe_id: Optional[int] = None recipe_id: Optional[int] = None
@@ -57,8 +127,8 @@ class MeasurementQuery(BaseModel):
measured_by: Optional[int] = None measured_by: Optional[int] = None
lot_number: Optional[str] = None lot_number: Optional[str] = None
serial_number: Optional[str] = None serial_number: Optional[str] = None
date_from: Optional[datetime] = None date_from: UtcNaiveInput = None
date_to: Optional[datetime] = None date_to: UtcNaiveInput = None
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$") pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
page: int = Field(1, ge=1) page: int = Field(1, ge=1)
per_page: int = Field(50, ge=1, le=500) per_page: int = Field(50, ge=1, le=500)
+49
View File
@@ -0,0 +1,49 @@
"""API models for the live station monitor (#57)."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class MonitorSubtask(BaseModel):
"""Mean of the recent measurements of one quote, against its tolerance."""
subtask_id: int
description: str
unit: str
count: int
mean: Optional[float] = None # #118: None = solo quote NaN
ltl: Optional[float] = None
utl: Optional[float] = None
# True when ltl <= mean <= utl (a missing limit does not constrain).
in_tolerance: bool
class MonitorEvent(BaseModel):
event_type: str
created_at: datetime
note: Optional[str] = None
class MonitorRun(BaseModel):
id: int
station_code: str
station_name: str
recipe_code: str
recipe_name: str
version_number: int
operator: str
status: str
started_at: datetime
duration_seconds: int
cycle_count: int
seconds_to_next_measurement: Optional[int] = None
overdue: bool
last_event: Optional[MonitorEvent] = None
# Counts over the last MONITOR_WINDOW measurements of the run.
measured: int
pass_count: int
warning_count: int
fail_count: int
subtasks: list[MonitorSubtask]
server_time: datetime
+104
View File
@@ -0,0 +1,104 @@
"""Pydantic schemas for production runs and their events."""
from src.backend.models.api.tipi import UtcDateTime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
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: UtcDateTime
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[UtcDateTime]
cycle_count: int
started_at: UtcDateTime
paused_at: Optional[UtcDateTime]
closed_at: Optional[UtcDateTime]
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[UtcDateTime] = 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)
# #53: task 'note' con at_close, da compilare prima della modale del capoturno.
close_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."""
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
supervisor_username: Optional[str] = Field(None, min_length=1)
supervisor_password: Optional[str] = Field(None, min_length=1)
supervisor_user_id: Optional[int] = Field(None, gt=0)
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
@model_validator(mode="after")
def _one_credential_pair(self):
by_password = self.supervisor_username and self.supervisor_password
by_pin = self.supervisor_user_id and self.supervisor_pin
if not (by_password or by_pin):
raise ValueError(
"supervisor credentials required: username+password or user_id+pin"
)
return self
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",
]
+22 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Recipe and RecipeVersion operations.""" """Pydantic schemas for Recipe and RecipeVersion operations."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -15,18 +15,32 @@ class RecipeCreate(BaseModel):
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) measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
# Rules of the recipe: what the operator must supply, and how a value may be
# entered. Manual input defaults to forbidden - the caliper is the instrument.
requires_lot: bool = False
requires_serial: bool = False
allow_manual_input: bool = False
# Optional task-level fields for the initial technical drawing # Optional task-level fields for the initial technical drawing
file_path: Optional[str] = Field(None, max_length=500) file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict] = None annotations_json: Optional[dict] = None
class RecipeDuplicate(BaseModel):
"""Schema for duplicating a recipe (#52): only the new code and name."""
code: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
class RecipeUpdate(BaseModel): class RecipeUpdate(BaseModel):
"""Schema for updating a recipe (creates new version).""" """Schema for updating a recipe (creates new version)."""
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) measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
requires_lot: Optional[bool] = None
requires_serial: Optional[bool] = None
allow_manual_input: Optional[bool] = None
change_notes: Optional[str] = None change_notes: Optional[str] = None
# Task-level fields: saved to the first task of the new version # Task-level fields: saved to the first task of the new version
file_path: Optional[str] = Field(None, max_length=500) file_path: Optional[str] = Field(None, max_length=500)
@@ -43,7 +57,7 @@ class RecipeVersionResponse(BaseModel):
version_number: int version_number: int
is_current: bool is_current: bool
created_by: int created_by: int
created_at: datetime created_at: UtcDateTime
change_notes: Optional[str] = None change_notes: Optional[str] = None
tasks: list["TaskResponse"] = [] tasks: list["TaskResponse"] = []
@@ -58,8 +72,13 @@ class RecipeResponse(BaseModel):
description: Optional[str] = None description: Optional[str] = None
image_path: Optional[str] = None image_path: Optional[str] = None
measurement_interval_minutes: Optional[int] = None measurement_interval_minutes: Optional[int] = None
# The operator's screen reads these to know whether to ask for lot and serial
# before starting, and whether to offer the keypad at all.
requires_lot: bool = False
requires_serial: bool = False
allow_manual_input: bool = False
created_by: int created_by: int
created_at: datetime created_at: UtcDateTime
active: bool active: bool
current_version: Optional[RecipeVersionResponse] = None current_version: Optional[RecipeVersionResponse] = None
+65 -4
View File
@@ -1,8 +1,37 @@
"""Pydantic schemas for Station and StationRecipeAssignment.""" """Pydantic schemas for Station and StationRecipeAssignment."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
def _normalize_http_url(value: Optional[str], field: str) -> Optional[str]:
"""Vuoto/spazi -> None; altrimenti http(s) senza '/' finale."""
if value is None:
return None
value = value.strip()
if not value:
return None
if not value.lower().startswith(("http://", "https://")):
raise ValueError(f"{field} must start with http:// or https://")
return value.rstrip("/")
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
return _normalize_http_url(value, "vision_worker_url")
def _normalize_camera_url(value: Optional[str]) -> Optional[str]:
"""Vuoto/spazi -> None (nessuna camera); altrimenti http(s) senza '/' finale (#72)."""
return _normalize_http_url(value, "camera_url")
def _normalize_camera_source(value: Optional[str]) -> Optional[str]:
"""Strip; vuoto -> None (#72)."""
if value is None:
return None
return value.strip() or None
class StationCreate(BaseModel): class StationCreate(BaseModel):
@@ -11,6 +40,15 @@ class StationCreate(BaseModel):
location: Optional[str] = Field(default=None, max_length=255) location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None notes: Optional[str] = None
active: bool = True active: bool = True
# worker di visione della stazione (#45); vuoto = centrale
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
# camera della stazione (#72); vuoto = nessuna camera
camera_url: Optional[str] = Field(default=None, max_length=500)
camera_source: Optional[str] = Field(default=None, max_length=200)
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
class StationUpdate(BaseModel): class StationUpdate(BaseModel):
@@ -18,6 +56,15 @@ class StationUpdate(BaseModel):
location: Optional[str] = Field(default=None, max_length=255) location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None notes: Optional[str] = None
active: Optional[bool] = None active: Optional[bool] = None
# worker di visione della stazione (#45); "" -> None riporta al centrale
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
# camera della stazione (#72); vuoto = nessuna camera
camera_url: Optional[str] = Field(default=None, max_length=500)
camera_source: Optional[str] = Field(default=None, max_length=200)
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
class StationResponse(BaseModel): class StationResponse(BaseModel):
@@ -28,8 +75,11 @@ class StationResponse(BaseModel):
location: Optional[str] location: Optional[str]
notes: Optional[str] notes: Optional[str]
active: bool active: bool
vision_worker_url: Optional[str] = None # #45
camera_url: Optional[str] = None # #72
camera_source: Optional[str] = None # #72
created_by: int created_by: int
created_at: datetime created_at: UtcDateTime
class StationRecipeAssignmentCreate(BaseModel): class StationRecipeAssignmentCreate(BaseModel):
@@ -42,7 +92,7 @@ class StationRecipeAssignmentResponse(BaseModel):
station_id: int station_id: int
recipe_id: int recipe_id: int
assigned_by: int assigned_by: int
assigned_at: datetime assigned_at: UtcDateTime
class RecipeSummary(BaseModel): class RecipeSummary(BaseModel):
@@ -53,7 +103,18 @@ class RecipeSummary(BaseModel):
active: bool active: bool
image_path: Optional[str] = None image_path: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
# The selection screen has to know before the operator starts: what a recipe
# demands is the difference between an enabled Avvia and a disabled one.
requires_lot: bool = False
requires_serial: bool = False
class StationWithRecipesResponse(StationResponse): class StationWithRecipesResponse(StationResponse):
recipes: list[RecipeSummary] = Field(default_factory=list) recipes: list[RecipeSummary] = Field(default_factory=list)
class StationResetResponse(BaseModel):
"""Outcome of clearing every recipe assignment of a station."""
station_id: int
removed: int
+7 -6
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for SPC statistics responses.""" """Pydantic schemas for SPC statistics responses."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
recipe_id: int recipe_id: int
version_id: Optional[int] = None version_id: Optional[int] = None
subtask_id: Optional[int] = None subtask_id: Optional[int] = None
date_from: Optional[datetime] = None date_from: UtcNaiveInput = None
date_to: Optional[datetime] = None date_to: UtcNaiveInput = None
operator_id: Optional[int] = None operator_id: Optional[int] = None
lot_number: Optional[str] = None lot_number: Optional[str] = None
serial_number: Optional[str] = None serial_number: Optional[str] = None
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
class ControlChartData(BaseModel): class ControlChartData(BaseModel):
"""Data for X-bar/R control chart.""" """Data for X-bar/R control chart."""
values: list[float] values: list[float]
timestamps: list[datetime] timestamps: list[UtcDateTime]
mean: float mean: float
ucl: float # Upper Control Limit ucl: float # Upper Control Limit
lcl: float # Lower Control Limit lcl: float # Lower Control Limit
@@ -50,7 +50,8 @@ class CapabilityData(BaseModel):
pp: Optional[float] = None pp: Optional[float] = None
ppk: Optional[float] = None ppk: Optional[float] = None
mean: float mean: float
std_dev: float std_dev: float # overall sample std dev (n-1), used for Pp/Ppk
std_dev_within: Optional[float] = None # MRbar/1.128, used for Cp/Cpk
n: int n: int
utl: Optional[float] = None utl: Optional[float] = None
ltl: Optional[float] = None ltl: Optional[float] = None
@@ -59,7 +60,7 @@ class CapabilityData(BaseModel):
class TrendData(BaseModel): class TrendData(BaseModel):
"""Temporal trend data for capability indices.""" """Temporal trend data for capability indices."""
dates: list[datetime] dates: list[UtcDateTime]
cpk_values: list[Optional[float]] cpk_values: list[Optional[float]]
ppk_values: list[Optional[float]] ppk_values: list[Optional[float]]
n_per_period: list[int] n_per_period: list[int]

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