Commit Graph

306 Commits

Author SHA1 Message Date
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