Compare commits

...

75 Commits

Author SHA1 Message Date
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
143 changed files with 9307 additions and 891 deletions
+41
View File
@@ -3,6 +3,8 @@
# ===========================
# --- 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_PORT=3306
DB_NAME=tiemeasureflow
@@ -10,12 +12,20 @@ DB_USER=tmflow
DB_PASSWORD=change_me_in_production
# --- 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_PORT=8000
SERVER_SECRET_KEY=change-this-to-a-random-secret-key
SERVER_CORS_ORIGINS=http://localhost:5000
# --- 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_PORT=5000
CLIENT_SECRET_KEY=change-this-to-another-random-secret-key
@@ -29,6 +39,11 @@ STATION_CODE=ST-DEFAULT
# 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.
@@ -48,12 +63,38 @@ MAX_UPLOAD_SIZE_MB=50
# --- Setup Page ---
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) ---
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
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
# --- Docker ---
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
DB_ROOT_PASSWORD=root_password_change_me
NGINX_PORT=80
NGINX_SSL_PORT=443
+6 -13
View File
@@ -33,19 +33,12 @@ env/
Thumbs.db
desktop.ini
# Uploads (server-side files, now at project root)
uploads/images/*
uploads/pdfs/*
uploads/logos/*
uploads/reports/*
uploads/general/
# Statistics files emitted when a production is closed: shop-floor evidence, not
# source. They live on the mounted volume in production.
uploads/statistics/
!uploads/images/.gitkeep
!uploads/pdfs/.gitkeep
!uploads/logos/.gitkeep
!uploads/reports/.gitkeep
# Uploads (server-side files, mounted as a volume in production): ignora tutto
# sotto uploads/, comprese le sottocartelle create a runtime (vision reference
# images, cartelle per ricetta/versione) — tieni solo i .gitkeep che mantengono
# le cartelle vuote in git.
uploads/*
!uploads/**/.gitkeep
# TailwindCSS output
src/frontend/flask_app/static/css/tailwind.css
+1
View File
@@ -0,0 +1 @@
*
+34 -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.
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/
@@ -18,10 +18,13 @@ TieMeasureFlow/
├── .python-version # 3.11
├── Dockerfile # Backend (uv + uvicorn)
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
├── Dockerfile.vision # Worker di visione (uv, Python 3.13)
├── docker-compose.dev.yml # Dev (Nginx)
├── docker-compose.yml # Prod (Traefik + SSL)
├── nginx/
├── uploads/ # Volume montato in /app/uploads
├── vendor/
│ └── visionsuite/ # Submodule: libreria di visione (vs-core, vs-task, vs-measure, vs-pm2d)
├── docs/
└── src/
├── backend/
@@ -38,16 +41,18 @@ TieMeasureFlow/
│ ├── migrations/ # Alembic
│ ├── templates/ # Setup page
│ └── tests/ # pytest
└── frontend/
└── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
├── app.py # giustificata: tablet UX server-side, USB calipers,
├── config.py # workflow operatore con Fabric.js)
├── blueprints/
├── services/
├── templates/
├── static/
├── translations/
└── tests/
├── frontend/
│ └── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
│ ├── app.py # giustificata: tablet UX server-side, USB calipers,
│ ├── config.py # workflow operatore con Fabric.js)
│ ├── blueprints/
│ ├── services/
│ ├── templates/
│ ├── static/
│ ├── translations/
│ └── tests/
├── 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`):
@@ -280,6 +285,23 @@ 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.
## Visione (V3.0.0)
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** su `ssh://git.tielogic.xyz:222`: 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`.
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
- Python 3.11+, type hints ovunque
- async/await per tutte le operazioni DB (server)
+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.
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.
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 \
&& 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
# 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/
# 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
+36 -20
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).
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.
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# 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
@@ -16,22 +37,17 @@ COPY pyproject.toml uv.lock ./
COPY .python-version ./
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/
# Build TailwindCSS (one-shot; no watcher in production image).
#
# Versione esatta, non `tailwindcss@3`. Con il vincolo aperto ogni ricostruzione
# dell'immagine poteva prendere una minor diversa e produrre un CSS diverso senza
# che nessuno l'avesse validato: per un sistema che produce evidenze per audit
# ISO 9001 / IATF 16949 è lo stesso problema che il punto 12 risolve per le
# librerie del browser. Aggiornarla è una decisione, non un effetto collaterale.
WORKDIR /app/flask_app
RUN npm install tailwindcss@3.4.19 && \
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
COPY --from=tailwind-build /app/flask_app/static/css/tailwind.css ./flask_app/static/css/tailwind.css
# 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
USER app
EXPOSE 5000
+14 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.13-slim
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.
@@ -6,7 +6,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
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 ./
@@ -34,6 +43,9 @@ RUN test -n "$VISION_ENGINE_VERSION" || { \
}
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.
+21 -12
View File
@@ -127,16 +127,25 @@ reparto.
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni, build delle immagini con `uv` e configurazione Nginx in un solo comando.
```bash
# 1. Clona il repository
git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
# 1. Clona il repository CON il sottomodulo di VisionSuite (vendor/visionsuite):
# 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
# Chi ha già clonato senza il sottomodulo:
# git submodule update --init
# 2. Configura le variabili d'ambiente
cp .env.example .env
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
# 3. Avvia i servizi (ambiente di sviluppo)
docker compose -f docker-compose.dev.yml up -d --build
# 3. Valorizza VISION_ENGINE_VERSION PRIMA della build: il worker di visione
# (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
docker compose -f docker-compose.dev.yml ps
@@ -460,15 +469,15 @@ variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando
mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml`
in produzione).
Stato corrente su `V3.0.0`, con `uv run pytest` su Python 3.11 e senza
l'extra `vision` (un conteggio senza il suo ambiente non dice nulla, regola
che vale anche qui): **390 pass, 1 fail, 4 skip** — 243 in `src/backend/tests/`,
147 pass + 2 skip in `src/frontend/flask_app/tests/`, più 2 skip in
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. Il fallimento è preesistente e indipendente da questo
lavoro: `test_offline.py::test_no_first_party_script_calls_out`, dovuto a una
copia locale di Fabric.js non tracciata da git (`static/js/fabric-debug.js`)
che nei propri commenti cita pagine di documentazione esterne.
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:
+28 -3
View File
@@ -14,6 +14,7 @@ services:
--authentication-policy=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--default-time-zone=+00:00
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
@@ -31,14 +32,23 @@ services:
env_file:
- .env
environment:
APP_ENV: dev
DB_HOST: mysql
UPLOAD_DIR: uploads
VISION_WORKER_URL: http://vision:8100
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1}
volumes:
- upload_data:/app/uploads
depends_on:
mysql:
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
@@ -50,6 +60,8 @@ services:
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
@@ -69,8 +81,19 @@ services:
- .env
environment:
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}
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:
- tmflow-net
@@ -87,8 +110,10 @@ services:
# - ./certbot/conf:/etc/letsencrypt:ro
# - ./certbot/www:/var/www/certbot:ro
depends_on:
- server
- client
server:
condition: service_healthy
client:
condition: service_healthy
networks:
- tmflow-net
+31 -8
View File
@@ -4,16 +4,17 @@ services:
container_name: tmflow-mysql
restart: unless-stopped
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_USER: ${DB_USER:-tmflow}
MYSQL_PASSWORD: ${DB_PASSWORD:-change_me_in_production}
MYSQL_PASSWORD: ${DB_PASSWORD:?manca DB_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
command: >
--authentication-policy=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--default-time-zone=+00:00
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
@@ -31,22 +32,33 @@ services:
env_file:
- .env
environment:
APP_ENV: production
DB_HOST: mysql
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:
- upload_data:/app/uploads
depends_on:
mysql:
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:
- "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.tls=true"
- "traefik.http.routers.tmflow-api.tls.certresolver=mytlschallenge"
- "traefik.http.routers.tmflow-api.priority=100"
- "traefik.http.services.tmflow-api.loadbalancer.server.port=8000"
- "traefik.http.routers.tmflow-api.middlewares=tmflow-headers"
networks:
- tmflow-net
- traefik-net
@@ -59,6 +71,8 @@ services:
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
@@ -78,23 +92,32 @@ services:
- .env
environment:
API_SERVER_URL: http://server:8000
CLIENT_SECRET_KEY: ${CLIENT_SECRET_KEY:?manca CLIENT_SECRET_KEY}
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:
- "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.tls=true"
- "traefik.http.routers.tmflow-web.tls.certresolver=mytlschallenge"
- "traefik.http.routers.tmflow-web.priority=10"
- "traefik.http.services.tmflow-web.loadbalancer.server.port=5000"
- "traefik.http.middlewares.tmflow-headers.headers.SSLRedirect=true"
- "traefik.http.middlewares.tmflow-headers.headers.STSSeconds=315360000"
- "traefik.http.middlewares.tmflow-headers.headers.browserXSSFilter=true"
# Traefik v3: SSLRedirect e browserXSSFilter non ci sono più (SSL redirect
# si fa a livello di entrypoint, l'XSS Auditor del browser non esiste più).
- "traefik.http.middlewares.tmflow-headers.headers.stsSeconds=315360000"
- "traefik.http.middlewares.tmflow-headers.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.tmflow-headers.headers.forceSTSHeader=true"
- "traefik.http.middlewares.tmflow-headers.headers.STSIncludeSubdomains=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"
networks:
- tmflow-net
+180 -281
View File
@@ -142,6 +142,19 @@ SSL_KEYFILE=
| `SSL_CERTFILE` | string | (empty) | Path to SSL certificate (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
@@ -169,63 +182,33 @@ EXIT;
### 2. Run Database Migrations
```bash
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:
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/`.
```bash
cd server
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())
"
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
```
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
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
```bash
cd server
pip install -r requirements.txt
uvicorn main:app --reload --host 0.0.0.0 --port 8000
uv sync --extra server --extra dev
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
```
Runs at `http://0.0.0.0:8000`
@@ -234,40 +217,15 @@ API Documentation:
- Swagger UI: `http://localhost:8000/docs`
- 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
pip install gunicorn
```
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
uv run uvicorn src.backend.main:app \
--host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY:-4} \
--proxy-headers --forwarded-allow-ips='*'
```
### systemd Service (Linux)
@@ -280,14 +238,14 @@ Description=TieMeasureFlow API Server
After=network.target mysql.service
[Service]
Type=notify
Type=simple
User=tiemeasureflow
WorkingDirectory=/opt/tiemeasureflow/server
Environment="PATH=/opt/tiemeasureflow/venv/bin"
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000
WorkingDirectory=/opt/tiemeasureflow
EnvironmentFile=/opt/tiemeasureflow/.env
Environment="WEB_CONCURRENCY=4"
ExecStart=/opt/tiemeasureflow/.venv/bin/uv run uvicorn src.backend.main:app \
--host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY} \
--proxy-headers --forwarded-allow-ips='*'
Restart=on-failure
RestartSec=10
@@ -315,12 +273,14 @@ sudo journalctl -u tiemeasureflow-api -f
## Client Deployment
Anche qui, Docker Compose è il metodo raccomandato; questa sezione è per lo
sviluppo o un'installazione manuale senza container.
### Development Server
```bash
cd client
pip install -r requirements.txt
flask run --host 0.0.0.0 --port 5000
uv sync --extra client --extra dev
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 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)
```bash
cd client
cd src/frontend/flask_app
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
pip install gunicorn
cd client
gunicorn --workers 4 --bind 0.0.0.0:5000 app:app
cd src/frontend/flask_app && uv run --project ../../.. gunicorn \
--workers 5 --threads 4 --worker-class gthread --timeout 60 \
--bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
"app:create_app()"
```
### systemd Service (Linux)
@@ -350,15 +314,14 @@ Description=TieMeasureFlow Web Client
After=network.target tiemeasureflow-api.service
[Service]
Type=notify
Type=simple
User=tiemeasureflow
WorkingDirectory=/opt/tiemeasureflow/client
Environment="PATH=/opt/tiemeasureflow/venv/bin"
Environment="FLASK_ENV=production"
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn \
--workers 4 \
--bind 0.0.0.0:5000 \
app:app
WorkingDirectory=/opt/tiemeasureflow/src/frontend/flask_app
EnvironmentFile=/opt/tiemeasureflow/.env
ExecStart=/opt/tiemeasureflow/.venv/bin/uv run --project /opt/tiemeasureflow gunicorn \
--workers 5 --threads 4 --worker-class gthread --timeout 60 \
--bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
"app:create_app()"
Restart=on-failure
RestartSec=10
@@ -373,8 +336,9 @@ WantedBy=multi-user.target
### 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)
- [ ] Set `APP_DOMAIN` to the real public domain (used by the Traefik router rules)
- [ ] Set CORS origins to actual client domains
- [ ] Enable SSL/HTTPS (see SSL/TLS section)
- [ ] Configure firewall rules
@@ -386,200 +350,140 @@ WantedBy=multi-user.target
### 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)
└────────┬────────┘
│
┌────┴────┐
│ │
┌───▼──┐ ┌───▼──┐
│API │ │Web │
│:8000 │ │:5000 │
└──────┘ └──────┘
│ │
└────┬─────┘
│
┌────▼────┐
│ MySQL │
│ :3306 │
└─────────┘
┌──────────────────────────┐
On-premise (LAN) │ Nginx (:80) │
└─────────────┬────────────┘
│
Produzione (dominio pubblico) → Traefik (:80/:443, TLS Let's Encrypt)
│
┌───────────┴───────────┐
│ │
┌─────▼─────┐ ┌──────▼─────┐
│ server │ │ client │
│ :8000 │ │ :5000 │
└─────┬──────┘ └────────────┘
│
┌─────▼──────┐ ┌─────────────┐
│ vision │ │ MySQL 8.0 │
│ :8100 │ │ :3306 │
│ (interno) │ └─────────────┘
└─────────────┘
```
### Nginx Configuration
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
```
Il worker di visione (`vision`) non pubblica porte verso l'esterno: lo
raggiunge solo il `server`, sulla rete Docker interna `tmflow-net`.
---
## 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
# Build images
docker-compose build
# Sviluppo / on-premise
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
docker compose -f docker-compose.dev.yml up -d --build
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
# Produzione
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
docker compose -f docker-compose.yml up -d --build
```
### Manual Docker Build
#### 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:
Comandi comuni (sintassi `docker compose` v2, senza trattino — il plugin, non
il vecchio `docker-compose` standalone):
```bash
docker build -f server/Dockerfile -t tiemeasureflow-api .
docker run -p 8000:8000 --env-file .env tiemeasureflow-api
docker compose -f docker-compose.yml ps
docker compose -f docker-compose.yml logs -f server
docker compose -f docker-compose.yml down
```
#### Web Client
### Aggiornare un'installazione esistente
```dockerfile
FROM python:3.11-slim
Le immagini precedenti creavano l'utente non privilegiato senza una home
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
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:
Prima del primo avvio con le nuove immagini, va quindi corretta la proprietà
del volume:
```bash
docker build -f client/Dockerfile -t tiemeasureflow-web .
docker run -p 5000:5000 --env-file .env tiemeasureflow-web
docker compose -f docker-compose.yml run --rm --user root --entrypoint chown server -R 10001:10001 /app/uploads
```
(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
### 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
openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes
```
- **Produzione** (`docker-compose.yml`): Traefik ottiene e rinnova da solo un
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)
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot certonly --nginx -d yourdomain.com
```
### 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
```
Un'installazione manuale (senza Docker, sezioni [Server](#server-deployment) e
[Client Deployment](#client-deployment) sopra) che debba terminare il TLS da
sola, dietro il proprio reverse proxy, può ancora valorizzare
`SSL_CERTFILE`/`SSL_KEYFILE` in `.env` — variabili lasciate per questo caso,
non lette da uvicorn/gunicorn nei comandi mostrati sopra: vanno passate al
proprio reverse proxy o gestite fuori da questo progetto.
---
@@ -644,14 +548,11 @@ sqlalchemy.exc.OperationalError: (asyncmy.errors.ProgrammingError) (2003, "Can't
**Check:**
```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"
# Check .env variables
# Check .env variables — non stampare mai la password: si controlla il valore, non la connection string
grep DB_ .env
# Test connection string
cd server && python -c "from config import settings; print(settings.database_url)"
```
### Port Already in Use
@@ -667,7 +568,7 @@ lsof -i :8000
kill -9 <PID>
# Or use different port
uvicorn main:app --port 8001
uv run uvicorn src.backend.main:app --port 8001
```
### Import Errors
@@ -678,21 +579,20 @@ ModuleNotFoundError: No module named 'fastapi'
**Solution:**
```bash
cd server
pip install -r requirements.txt
uv sync --extra server --extra client --extra dev
```
### File Upload Not Working
```
/app/server/uploads: Permission denied
uploads: Permission denied
```
**Solution:**
```bash
# Create uploads directory with correct permissions
mkdir -p server/uploads
chmod 755 server/uploads
# Create uploads directory with correct permissions (radice del repo, non server/uploads)
mkdir -p uploads/images uploads/pdfs uploads/logos uploads/reports
chmod 755 uploads
```
### Migrations Failed
@@ -703,13 +603,12 @@ alembic.util.exc.CommandError: Can't locate revision identified by...
**Solution:**
```bash
cd server
# Check migration status
alembic current
uv run alembic -c src/backend/migrations/alembic.ini current
# Reset migrations (CAUTION - deletes data)
alembic downgrade base
alembic upgrade head
uv run alembic -c src/backend/migrations/alembic.ini downgrade base
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
```
### API Key Not Working
+8 -9
View File
@@ -34,13 +34,6 @@ server {
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
location / {
proxy_pass http://client_frontend;
@@ -50,12 +43,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# Static file caching
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
# Static file caching. Limitata a /static/: un add_header in questo
# 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_set_header Host $host;
expires 7d;
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)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "tiemeasureflow"
version = "2.0.0"
version = "3.0.0"
description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations."
requires-python = ">=3.11"
authors = [
+90 -11
View File
@@ -21,16 +21,28 @@ are already in place:
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.
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, select
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
@@ -86,6 +98,43 @@ async def _find_recipes(session, 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:
@@ -176,25 +225,33 @@ async def _ensure_supervisor(session, username, password):
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
return existing, False, password
password_used = password or secrets.token_urlsafe(12)
user = await auth_service.create_user(
session,
username=username,
password=password,
password=password_used,
display_name="Capo Turno (collaudo)",
roles=["Supervisor", "MeasurementTec"],
)
await session.flush()
return user, True
return user, True, password_used
async def seed(station_code: str, replace: bool, supervisor_password: str) -> int:
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
@@ -217,6 +274,14 @@ async def seed(station_code: str, replace: bool, supervisor_password: str) -> in
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)}")
@@ -286,7 +351,7 @@ async def seed(station_code: str, replace: bool, supervisor_password: str) -> in
)
await _assign(session, station, recipe_b, owner.id)
supervisor, created = await _ensure_supervisor(
supervisor, created, password_used = await _ensure_supervisor(
session, "capoturno", supervisor_password,
)
@@ -296,7 +361,7 @@ async def seed(station_code: str, replace: bool, supervisor_password: str) -> in
print(" COLLAUDO-A ciclo di misura, lotto obbligatorio, intervallo 2 min")
print(" COLLAUDO-B solo calibro, lotto e seriale obbligatori")
if created:
print(f"Capoturno creato: {supervisor.username} / {supervisor_password}")
print(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)")
@@ -309,11 +374,25 @@ def main() -> int:
help="codice della stazione a cui assegnare le ricette")
parser.add_argument("--replace", action="store_true",
help="cancella le ricette di collaudo esistenti e le misure fatte su di esse")
parser.add_argument("--supervisor-password", default="Collaudo2026!",
help="password del capoturno, se va creato")
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)
seed(args.station, args.replace, args.supervisor_password, args.si)
)
+22 -4
View File
@@ -1,10 +1,18 @@
"""API Key authentication dependency for FastAPI."""
from datetime import timedelta
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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.services.auth_service import hash_api_key
from src.backend.utils.tempo import utcnow
_RENEW_INTERVAL = timedelta(minutes=5)
async def get_current_user(
@@ -24,15 +32,25 @@ async def get_current_user(
)
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(
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
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 sqlalchemy import insert
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
from src.backend.database import async_session_factory
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):
"""Middleware that logs every API request to the access_logs table."""
@@ -30,11 +35,12 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
# Log asynchronously (don't block response)
try:
action = f"{request.method} {request.url.path}"[:_ACTION_MAX_LENGTH]
async with async_session_factory() as session:
await session.execute(
insert(AccessLog).values(
user_id=user_id,
action=f"{request.method} {request.url.path}",
action=action,
details={
"method": request.method,
"path": request.url.path,
@@ -42,7 +48,7 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
"status_code": response.status_code,
"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],
)
)
+83 -19
View File
@@ -1,8 +1,10 @@
"""Rate limiting middleware for FastAPI.
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
from collections import defaultdict
from typing import Callable
@@ -17,14 +19,32 @@ from src.backend.config import settings
class RateLimitMiddleware(BaseHTTPMiddleware):
"""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.
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"
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:
super().__init__(app)
# {ip: [timestamp, ...]} per bucket
@@ -34,23 +54,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
@staticmethod
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``,
``request.client.host``. Required because Nginx and the Flask client
sit between the tablet and the API; without parsing these headers
every tablet shares one bucket.
Nginx (and, behind it, the Flask client) each add one hop to
X-Forwarded-For. The *leftmost* entries are whatever the original
caller claimed and are trivially spoofed; trust only extends to the
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")
if xff:
first = xff.split(",")[0].strip()
if first:
return first
real = request.headers.get("x-real-ip")
if real:
return real.strip()
count = settings.trusted_proxy_count
if count > 0:
xff = request.headers.get("x-forwarded-for")
if xff:
hops = [h.strip() for h in xff.split(",") if h.strip()]
if len(hops) >= count:
return hops[-count]
# 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"
@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 == cls.LOGIN_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]:
"""Remove timestamps outside the current sliding window."""
cutoff = now - self.WINDOW_SECONDS
@@ -97,10 +158,12 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self._evict_stale_ips(self._login_requests, now)
self._evict_stale_ips(self._general_requests, now)
# Check login-specific rate limit
if path == self.LOGIN_PATH and request.method == "POST":
# Check login-bucket rate limit (login itself, supervisor-credential
# paths, and the setup password)
if self._is_login_bucket_request(request.method, path):
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:
return JSONResponse(
@@ -111,7 +174,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
# Check general 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:
return JSONResponse(
+8 -3
View File
@@ -1,5 +1,5 @@
"""Authentication router - login, logout, profile."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
@@ -34,11 +34,16 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
request: Request,
all_sessions: bool = False,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Logout - invalidate API key."""
await logout_user(db, user)
"""Logout. Deletes this session's API key only; ?all_sessions=true
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)
+147 -23
View File
@@ -6,9 +6,15 @@ from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from PIL import Image
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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.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
logger = logging.getLogger(__name__)
@@ -20,6 +26,92 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
ALLOWED_PDF_TYPE = "application/pdf"
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.
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/")
# 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:
"""Validate if file type is allowed."""
@@ -119,25 +211,36 @@ async def upload_file(
"""Upload an image or PDF file.
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
if not validate_file_type(file.content_type):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {file.content_type} not allowed. Must be image or PDF.",
)
# Read file content
content = await file.read()
# Read in chunks, stopping as soon as the size limit is crossed instead
# of buffering an oversized body into memory first.
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)
file_size = len(content)
# Validate file size
if not validate_file_size(file_size):
# Validate file type from the actual bytes, not file.content_type
detected = detect_file_type(content)
if detected is None:
raise HTTPException(
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
if recipe_id is not None and version_id is not None:
@@ -147,18 +250,17 @@ async def upload_file(
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 "")
base_name = Path(safe_filename).stem or "file"
safe_filename = f"{base_name}{detected_ext}"
# Generate unique filename if file already exists
file_path = storage_dir / safe_filename
counter = 1
while file_path.exists():
name_parts = safe_filename.rsplit(".", 1)
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}"
file_path = storage_dir / f"{base_name}_{counter}{detected_ext}"
counter += 1
# Write file
@@ -166,7 +268,7 @@ async def upload_file(
# Generate thumbnail for images
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.mkdir(exist_ok=True)
thumbnail_path = thumbnail_dir / file_path.name
@@ -183,7 +285,7 @@ async def upload_file(
return {
"file_path": str(relative_path).replace("\\", "/"),
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
"file_type": file.content_type,
"file_type": detected_mime,
"file_size": file_size,
}
@@ -195,8 +297,14 @@ async def get_file(
):
"""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)
return FileResponse(full_path)
@@ -205,13 +313,29 @@ async def get_file(
async def delete_file(
file_path: str,
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""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)
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
if full_path.parent.name != "thumbnails":
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
+40 -11
View File
@@ -2,6 +2,8 @@
import csv
import io
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.responses import StreamingResponse
@@ -19,6 +21,7 @@ from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.user import User
from src.backend.models.api.measurement import (
CurrentCycleResponse,
MeasurementAuthorisation,
MeasurementBatchCreate,
MeasurementCreate,
@@ -28,6 +31,7 @@ from src.backend.models.api.measurement import (
)
from src.backend.services import auth_service, measurement_service
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"])
@@ -96,6 +100,24 @@ async def get_task_progress(
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,
@@ -173,6 +195,7 @@ def _build_measurement_filters(
date_from: datetime | None,
date_to: datetime | None,
pass_fail: str | None,
production_run_id: int | None = None,
) -> list:
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
filters = []
@@ -198,6 +221,8 @@ def _build_measurement_filters(
filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None:
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
@@ -209,9 +234,10 @@ async def get_measurements(
measured_by: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
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),
per_page: int = Query(50, ge=1, le=500),
user: User = Depends(get_current_user),
@@ -227,7 +253,7 @@ async def get_measurements(
"""
filters = _build_measurement_filters(
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
@@ -266,15 +292,18 @@ async def export_measurements_csv(
measured_by: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Export measurements to CSV with configurable delimiter and decimal separator."""
# Check user has MeasurementTec or Metrologist role
if not (user.has_role("MeasurementTec") or user.has_role("Metrologist")):
# Check user has MeasurementTec or Metrologist role, same as require_role: an
# 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(
status_code=status.HTTP_403_FORBIDDEN,
detail="MeasurementTec or Metrologist role required",
@@ -342,10 +371,10 @@ async def export_measurements_csv(
value_str,
m.pass_fail,
deviation_str,
m.lot_number or "",
m.serial_number or "",
neutralise_csv_cell(m.lot_number or ""),
neutralise_csv_cell(m.serial_number or ""),
m.input_method,
m.measured_at.isoformat(),
iso_utc(m.measured_at),
m.synced_to_csv,
])
@@ -355,6 +384,6 @@ async def export_measurements_csv(
iter([output.getvalue()]),
media_type="text/csv",
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"
},
)
+22 -7
View File
@@ -4,7 +4,7 @@ Deliberately stateless: nothing lives in process memory. With one station app
installed per PC the database is the only shared place, and any station app must be
able to ask "what is going on here?" and get the same answer.
"""
from fastapi import APIRouter, Depends, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.middleware.api_key import get_current_user
@@ -29,6 +29,21 @@ _DERIVED = {
}
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(
@@ -43,7 +58,7 @@ async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunRes
@router.post("", response_model=ProductionRunResponse, status_code=status.HTTP_201_CREATED)
async def open_production_run(
data: ProductionRunCreate,
user: User = Depends(get_current_user),
user: User = Depends(_require_run_operator),
db: AsyncSession = Depends(get_db),
):
"""Start a production at a station."""
@@ -85,7 +100,7 @@ async def get_production_run(
async def complete_measurement_cycle(
run_id: int,
payload: CycleCompletePayload | None = None,
user: User = Depends(get_current_user),
user: User = Depends(_require_run_operator),
db: AsyncSession = Depends(get_db),
):
"""Record a finished measurement task; the last one restarts the interval."""
@@ -102,7 +117,7 @@ async def complete_measurement_cycle(
async def remeasure_in_cycle(
run_id: int,
payload: RemeasurePayload | None = None,
user: User = Depends(get_current_user),
user: User = Depends(_require_run_operator),
db: AsyncSession = Depends(get_db),
):
"""Turn the piece over and measure again, without closing the cycle."""
@@ -119,7 +134,7 @@ async def remeasure_in_cycle(
async def pause_production_run(
run_id: int,
action: SupervisorAction,
user: User = Depends(get_current_user),
user: User = Depends(_require_run_operator),
db: AsyncSession = Depends(get_db),
):
"""Fermo linea - requires a supervisor."""
@@ -135,7 +150,7 @@ async def pause_production_run(
async def resume_production_run(
run_id: int,
action: SupervisorAction,
user: User = Depends(get_current_user),
user: User = Depends(_require_run_operator),
db: AsyncSession = Depends(get_db),
):
"""Restart a stopped line - requires a supervisor."""
@@ -151,7 +166,7 @@ async def resume_production_run(
async def close_production_run(
run_id: int,
action: SupervisorAction,
user: User = Depends(get_current_user),
user: User = Depends(_require_run_operator),
db: AsyncSession = Depends(get_db),
):
"""Fine produzione - requires a supervisor. Stops the timer for good."""
+5
View File
@@ -189,6 +189,11 @@ async def parse_technical_sheet(
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except 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:
logging.getLogger(__name__).error("AI parsing error: %s", e)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
+5 -5
View File
@@ -1,5 +1,5 @@
"""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.responses import Response
@@ -18,8 +18,8 @@ async def download_spc_report(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
@@ -52,8 +52,8 @@ async def download_measurement_report(
recipe_id: int = Query(...),
subtask_id: int | None = Query(None),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
+67 -20
View File
@@ -1,6 +1,4 @@
"""System settings router - read/update settings, upload company logo."""
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,11 +6,48 @@ from sqlalchemy.ext.asyncio import AsyncSession
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_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.user import User
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_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 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("/")
async def get_settings(
@@ -49,6 +84,8 @@ async def update_settings(
updated_keys = []
for key, value in settings_data.items():
_validate_setting_value(key, value)
# Check if setting exists
result = await db.execute(
select(SystemSetting).where(SystemSetting.setting_key == key)
@@ -88,35 +125,45 @@ async def upload_company_logo(
"""Upload company logo.
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)
allowed_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
if file.content_type not in allowed_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {file.content_type} not allowed. Must be an image.",
)
# Read file content
content = await file.read()
# Read in chunks, stopping as soon as the size limit is crossed instead
# of buffering an oversized body into memory first.
max_bytes = settings.max_upload_size_mb * 1024 * 1024
buffer = bytearray()
chunk_size = 1024 * 1024
while True:
chunk = await file.read(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)
file_size = len(content)
# Validate file size
max_bytes = settings.max_upload_size_mb * 1024 * 1024
if file_size > max_bytes:
# Validate file type from the actual bytes (SVG never matches: refused)
detected = detect_file_type(content)
if detected is None or detected[0] not in ALLOWED_IMAGE_TYPES:
raise HTTPException(
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
logos_dir = settings.upload_path / "logos"
logos_dir.mkdir(parents=True, exist_ok=True)
# Use a fixed filename for the company logo
file_extension = Path(file.filename).suffix
logo_filename = f"company_logo{file_extension}"
# Fixed filename for the company logo, extension forced to what was
# actually detected (not what the client claimed or the filename said).
logo_filename = f"company_logo{detected_ext}"
logo_path = logos_dir / logo_filename
# Write file
+91 -16
View File
@@ -3,11 +3,14 @@
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
NOT protected by API Key auth (standalone access).
"""
import hmac
import random
import secrets
from datetime import datetime, timedelta
from src.backend.utils.tempo import iso_utc, utcnow
from pathlib import Path
from alembic import command
from alembic.config import Config
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
@@ -22,15 +25,29 @@ from src.backend.models.orm import (
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
)
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
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))
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
# ---------------------------------------------------------------------------
@@ -87,13 +104,57 @@ def _check_setup_enabled() -> None:
def _check_password(password: str) -> None:
"""Verify the provided password matches the setup password."""
_check_setup_enabled()
if password != settings.setup_password:
if not hmac.compare_digest(password, settings.setup_password):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
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(
subtask: RecipeSubtask,
version_id: int,
@@ -220,14 +281,24 @@ async def setup_page(request: Request):
@router.get("/status")
async def setup_status():
"""Return database table status (no password required, but setup must be enabled)."""
async def setup_status(password: str | None = None):
"""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()
initialised = await _admin_exists()
if not _password_matches(password):
return {"initialised": initialised}
async with engine.connect() as conn:
table_names: list[str] = await conn.run_sync(
lambda sync_conn: sa_inspect(sync_conn).get_table_names()
)
return {
"initialised": initialised,
"tables_exist": len(table_names) > 0,
"table_count": len(table_names),
"tables": table_names,
@@ -238,8 +309,10 @@ async def setup_status():
async def init_db(body: PasswordBody):
"""Create all database tables."""
_check_password(body.password)
await _require_not_initialised()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
_stamp_head()
return {"status": "ok", "message": "Database tables created successfully"}
@@ -248,8 +321,6 @@ async def create_admin(body: CreateAdminBody):
"""Create an admin user with all roles."""
_check_password(body.password)
api_key = secrets.token_hex(32)
async with async_session_factory() as session:
async with session.begin():
user = User(
@@ -259,9 +330,10 @@ async def create_admin(body: CreateAdminBody):
display_name=body.admin_display_name,
roles=["Maker", "MeasurementTec", "Metrologist"],
is_admin=True,
api_key=api_key,
)
session.add(user)
await session.flush()
api_key = await issue_api_key(session, user)
return {
"status": "ok",
@@ -274,6 +346,7 @@ async def create_admin(body: CreateAdminBody):
async def seed_demo_data(body: PasswordBody):
"""Seed database with demo users and a sample recipe."""
_check_password(body.password)
await _require_not_initialised()
async with async_session_factory() as session:
async with session.begin():
@@ -330,7 +403,6 @@ async def seed_demo_data(body: PasswordBody):
email=u["email"],
roles=u["roles"],
is_admin=u["is_admin"],
api_key=secrets.token_hex(32),
)
session.add(user)
created_users.append(user)
@@ -484,7 +556,7 @@ async def seed_demo_data(body: PasswordBody):
tec_user = created_users[2] # tec1
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)]
base_date = datetime.now()
base_date = utcnow()
all_subtasks = subtasks_1 + subtasks_2
measurements_created = 0
@@ -532,8 +604,8 @@ async def list_users(body: PasswordBody):
"roles": u.roles or [],
"is_admin": u.is_admin,
"active": u.active,
"created_at": u.created_at.isoformat() if u.created_at else None,
"last_login": u.last_login.isoformat() if u.last_login else None,
"created_at": iso_utc(u.created_at) or None,
"last_login": iso_utc(u.last_login) or None,
}
for u in users
],
@@ -544,6 +616,7 @@ async def list_users(body: PasswordBody):
async def manage_user(body: ManageUserBody):
"""Create or update a user. user_id=null creates new, user_id=int updates."""
_check_password(body.password)
await _require_not_initialised()
invalid = set(body.roles) - VALID_ROLES
if invalid:
@@ -577,7 +650,6 @@ async def manage_user(body: ManageUserBody):
email=body.email,
roles=body.roles,
is_admin=body.is_admin,
api_key=secrets.token_hex(32),
)
session.add(user)
await session.flush()
@@ -618,6 +690,7 @@ async def manage_user(body: ManageUserBody):
async def change_user_password(body: ChangeUserPasswordBody):
"""Change a user's password and invalidate their API key."""
_check_password(body.password)
await _require_not_initialised()
async with async_session_factory() as session:
async with session.begin():
@@ -631,7 +704,7 @@ async def change_user_password(body: ChangeUserPasswordBody):
detail=f"User ID {body.user_id} not found",
)
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}'"}
@@ -654,7 +727,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
)
user.active = 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"
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
@@ -664,6 +737,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
async def reset_db(body: ResetDbBody):
"""Drop all tables and recreate them. Requires confirm=true."""
_check_password(body.password)
await _require_not_initialised()
if not body.confirm:
raise HTTPException(
@@ -674,5 +748,6 @@ async def reset_db(body: ResetDbBody):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
_stamp_head()
return {"status": "ok", "message": "Database reset successfully"}
+9 -8
View File
@@ -1,5 +1,6 @@
"""Statistics router - SPC endpoints for Metrologist dashboard."""
from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, select
@@ -100,8 +101,8 @@ async def get_summary(
recipe_id: int = Query(...),
version_id: int | None = Query(None),
subtask_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
@@ -122,8 +123,8 @@ async def get_capability(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
@@ -145,8 +146,8 @@ async def get_control_chart(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
@@ -172,8 +173,8 @@ async def get_histogram(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
+125 -13
View File
@@ -17,8 +17,10 @@ from src.backend.models.api.task import (
TaskReorderRequest,
TaskResponse,
TaskUpdate,
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"])
@@ -99,6 +101,64 @@ async def _ensure_version_is_current(db: AsyncSession, version_id: int) -> Recip
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
# ---------------------------------------------------------------------------
@@ -264,10 +324,16 @@ async def reorder_tasks(
async def update_task(
task_id: int,
data: TaskUpdate,
_user: User = Depends(require_maker),
user: User = Depends(require_maker),
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)
await _ensure_version_is_current(db, task.version_id)
@@ -275,6 +341,8 @@ async def update_task(
if not update_data:
return TaskResponse.model_validate(task)
task = await _task_for_write(db, task, user)
for field, value in update_data.items():
setattr(task, field, value)
@@ -286,18 +354,37 @@ async def update_task(
@router.delete("/api/tasks/{task_id}", status_code=204)
async def delete_task(
task_id: int,
_user: User = Depends(require_maker),
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""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)
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.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)
# ---------------------------------------------------------------------------
@@ -312,16 +399,19 @@ async def delete_task(
async def create_subtask(
task_id: int,
data: SubtaskCreate,
_user: User = Depends(require_maker),
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""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)
await _ensure_version_is_current(db, task.version_id)
task = await _task_for_write(db, task, user)
# Check marker_number uniqueness within the task
existing_markers = {s.marker_number for s in task.subtasks}
if data.marker_number in existing_markers:
@@ -331,7 +421,7 @@ async def create_subtask(
)
subtask = RecipeSubtask(
task_id=task_id,
task_id=task.id,
marker_number=data.marker_number,
description=data.description,
measurement_type=data.measurement_type,
@@ -354,12 +444,13 @@ async def create_subtask(
async def update_subtask(
subtask_id: int,
data: SubtaskUpdate,
_user: User = Depends(require_maker),
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""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)
@@ -371,6 +462,24 @@ async def update_subtask(
if not update_data:
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():
setattr(subtask, field, value)
@@ -382,12 +491,13 @@ async def update_subtask(
@router.delete("/api/subtasks/{subtask_id}", status_code=204)
async def delete_subtask(
subtask_id: int,
_user: User = Depends(require_maker),
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""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)
@@ -395,5 +505,7 @@ async def delete_subtask(
task = await _get_task_or_404(db, subtask.task_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.flush()
+46 -5
View File
@@ -1,13 +1,13 @@
"""Users router - CRUD operations (admin only)."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_admin_user
from src.backend.models.orm.user import User
from src.backend.models.api.user import 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
router = APIRouter(prefix="/api/users", tags=["users"])
@@ -78,6 +78,21 @@ async def update_user(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
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
if "roles" in update_data:
for role in update_data["roles"]:
@@ -86,6 +101,27 @@ async def update_user(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
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():
setattr(user, field, value)
await db.flush()
@@ -100,12 +136,17 @@ async def change_user_password(
admin: User = Depends(require_admin_user),
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))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.password_hash = hash_password(data.password)
await revoke_all_keys(db, user.id)
await db.flush()
return {"message": f"Password changed for user {user.username}"}
@@ -127,7 +168,7 @@ async def deactivate_user(
detail="Cannot deactivate yourself",
)
user.active = False
user.api_key = None
await revoke_all_keys(db, user.id)
await db.flush()
@@ -142,5 +183,5 @@ async def regenerate_user_key(
user = result.scalar_one_or_none()
if user is None:
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}"}
+88 -6
View File
@@ -4,7 +4,10 @@ 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 io
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from PIL import Image
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,7 +15,9 @@ from src.backend.api.middleware.api_key import require_maker, require_measuremen
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, validate_file_size
from src.backend.config import settings
from src.backend.database import get_db
from src.backend.models.api.vision import VisionExecuteResponse, VisionMeasurementResult
from src.backend.models.api.vision import (
VisionExecuteResponse, VisionMeasurementResult, VisionReferenceImageResponse,
)
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import vision_service
@@ -28,6 +33,29 @@ _IMAGE_EXTENSIONS = {
"image/webp": "webp",
}
# 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). BMP and
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
# because PIL - and so the worker - can decode both; 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."""
@@ -60,16 +88,39 @@ async def _read_image(image: UploadFile) -> bytes:
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),
serial_number: str | None = Form(None),
production_run_id: int | None = Form(None),
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:
@@ -100,7 +151,7 @@ async def execute(
# Composing a recipe and keeping a reference image are Maker operations, not
# measuring: both endpoints below gate on `require_maker`, not the
# measuring: every endpoint below gates on `require_maker`, not the
# `require_measurement_tec` that `execute` above uses.
@@ -118,10 +169,41 @@ async def add_reference_image(
db, task.id, content, note=note,
extension=_IMAGE_EXTENSIONS[image.content_type],
)
await db.commit()
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(...),
+59 -2
View File
@@ -1,23 +1,54 @@
"""TieMeasureFlow Server Configuration."""
from pathlib import Path
from pydantic import model_validator
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):
"""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
db_host: str = "localhost"
db_port: int = 3306
db_name: str = "tiemeasureflow"
db_user: str = "tmflow"
db_password: str = "change_me_in_production"
db_password: str = _DEFAULT_DB_PASSWORD
# Presentation time zone (DB is UTC, #42)
display_timezone: str = "Europe/Rome"
# Server
server_host: str = "0.0.0.0"
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"
# 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
upload_dir: str = "uploads"
@@ -33,6 +64,10 @@ class Settings(BaseSettings):
# Setup page (empty = disabled)
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)
openrouter_api_key: str | None = None
@@ -72,5 +107,27 @@ class Settings(BaseSettings):
"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
)
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()
@@ -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: 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
depends_on: Union[str, Sequence[str], None] = None
@@ -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,54 @@
"""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:
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'
),
)
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
with op.batch_alter_table('users') as batch:
batch.drop_index('ix_users_api_key')
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')
+27 -5
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Measurement operations."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
@@ -9,6 +9,13 @@ class MeasurementCreate(BaseModel):
"""Schema for creating a single measurement."""
subtask_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
lot_number: Optional[str] = Field(None, max_length=100)
serial_number: Optional[str] = Field(None, max_length=100)
@@ -47,8 +54,8 @@ class MeasurementResponse(BaseModel):
# Who let this value stand, when it was out of tolerance. Null on a value in
# tolerance - and on one that is not and is still waiting for an answer.
supervisor_id: Optional[int] = None
authorised_at: Optional[datetime] = None
measured_at: datetime
authorised_at: Optional[UtcDateTime] = None
measured_at: UtcDateTime
synced_to_csv: bool
@@ -84,6 +91,21 @@ class TaskProgressListResponse(BaseModel):
tasks: list[TaskProgressResponse]
class CycleMeasurementResponse(BaseModel):
"""One quote already measured in the cycle under way."""
subtask_id: int
value: float
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):
"""Schema for measurement query filters."""
recipe_id: Optional[int] = None
@@ -92,8 +114,8 @@ class MeasurementQuery(BaseModel):
measured_by: Optional[int] = None
lot_number: Optional[str] = None
serial_number: Optional[str] = None
date_from: Optional[datetime] = None
date_to: Optional[datetime] = None
date_from: UtcNaiveInput = None
date_to: UtcNaiveInput = None
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
page: int = Field(1, ge=1)
per_page: int = Field(50, ge=1, le=500)
+7 -7
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for production runs and their events."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
@@ -21,7 +21,7 @@ class ProductionEventResponse(BaseModel):
supervisor_id: Optional[int]
task_id: Optional[int] = None
note: Optional[str]
created_at: datetime
created_at: UtcDateTime
class ProductionRunResponse(BaseModel):
@@ -36,11 +36,11 @@ class ProductionRunResponse(BaseModel):
serial_number: Optional[str]
status: str
measurement_interval_minutes: Optional[int]
next_measurement_at: Optional[datetime]
next_measurement_at: Optional[UtcDateTime]
cycle_count: int
started_at: datetime
paused_at: Optional[datetime]
closed_at: Optional[datetime]
started_at: UtcDateTime
paused_at: Optional[UtcDateTime]
closed_at: Optional[UtcDateTime]
closed_by: Optional[int]
statistics_path: Optional[str] = None
@@ -49,7 +49,7 @@ class ProductionRunResponse(BaseModel):
# overdue is a fact the operator must see, not deduce.
seconds_to_next_measurement: Optional[int] = None
overdue: bool = False
server_time: Optional[datetime] = None
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
+3 -3
View File
@@ -1,5 +1,5 @@
"""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 pydantic import BaseModel, ConfigDict, Field
@@ -51,7 +51,7 @@ class RecipeVersionResponse(BaseModel):
version_number: int
is_current: bool
created_by: int
created_at: datetime
created_at: UtcDateTime
change_notes: Optional[str] = None
tasks: list["TaskResponse"] = []
@@ -72,7 +72,7 @@ class RecipeResponse(BaseModel):
requires_serial: bool = False
allow_manual_input: bool = False
created_by: int
created_at: datetime
created_at: UtcDateTime
active: bool
current_version: Optional[RecipeVersionResponse] = None
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Station and StationRecipeAssignment."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
@@ -29,7 +29,7 @@ class StationResponse(BaseModel):
notes: Optional[str]
active: bool
created_by: int
created_at: datetime
created_at: UtcDateTime
class StationRecipeAssignmentCreate(BaseModel):
@@ -42,7 +42,7 @@ class StationRecipeAssignmentResponse(BaseModel):
station_id: int
recipe_id: int
assigned_by: int
assigned_at: datetime
assigned_at: UtcDateTime
class RecipeSummary(BaseModel):
+7 -6
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for SPC statistics responses."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional
from pydantic import BaseModel, Field
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
recipe_id: int
version_id: Optional[int] = None
subtask_id: Optional[int] = None
date_from: Optional[datetime] = None
date_to: Optional[datetime] = None
date_from: UtcNaiveInput = None
date_to: UtcNaiveInput = None
operator_id: Optional[int] = None
lot_number: Optional[str] = None
serial_number: Optional[str] = None
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
class ControlChartData(BaseModel):
"""Data for X-bar/R control chart."""
values: list[float]
timestamps: list[datetime]
timestamps: list[UtcDateTime]
mean: float
ucl: float # Upper Control Limit
lcl: float # Lower Control Limit
@@ -50,7 +50,8 @@ class CapabilityData(BaseModel):
pp: Optional[float] = None
ppk: Optional[float] = None
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
utl: Optional[float] = None
ltl: Optional[float] = None
@@ -59,7 +60,7 @@ class CapabilityData(BaseModel):
class TrendData(BaseModel):
"""Temporal trend data for capability indices."""
dates: list[datetime]
dates: list[UtcDateTime]
cpk_values: list[Optional[float]]
ppk_values: list[Optional[float]]
n_per_period: list[int]
+31 -1
View File
@@ -1,7 +1,7 @@
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from src.backend.models.orm.task import TASK_TYPES
@@ -9,6 +9,31 @@ from src.backend.models.orm.task import TASK_TYPES
TaskType = Literal[TASK_TYPES]
def validate_tolerance_order(
ltl: Optional[float],
lwl: Optional[float],
nominal: Optional[float],
uwl: Optional[float],
utl: Optional[float],
) -> None:
"""ltl <= lwl <= nominal <= uwl <= utl, checked only across the values set.
A one-sided tolerance - only utl, say - is not an error: what is wrong is
limits that invert the sequence they do share, like utl landing below ltl.
Shared by SubtaskCreate's own validator and by the router's partial-update
check (SubtaskUpdate has no fixed set of fields to compare).
"""
ordered = [
("ltl", ltl), ("lwl", lwl), ("nominal", nominal), ("uwl", uwl), ("utl", utl),
]
present = [(name, value) for name, value in ordered if value is not None]
for (prev_name, prev), (name, value) in zip(present, present[1:]):
if prev > value:
raise ValueError(
f"Tolerance limits out of order: {prev_name}={prev} > {name}={value}"
)
class SubtaskCreate(BaseModel):
"""Schema for creating a subtask."""
marker_number: int = Field(..., ge=1)
@@ -23,6 +48,11 @@ class SubtaskCreate(BaseModel):
image_path: Optional[str] = Field(None, max_length=500)
vision_output: Optional[str] = None
@model_validator(mode="after")
def _check_tolerance_order(self) -> "SubtaskCreate":
validate_tolerance_order(self.ltl, self.lwl, self.nominal, self.uwl, self.utl)
return self
class SubtaskUpdate(BaseModel):
"""Schema for updating a subtask."""
+14
View File
@@ -0,0 +1,14 @@
"""Shared Pydantic types for datetimes (#42): UTC in the DB, explicit +00:00 on the wire."""
from datetime import datetime
from typing import Annotated, Optional
from pydantic import AfterValidator, PlainSerializer
from src.backend.utils.tempo import as_utc_naive, to_utc_aware
UtcDateTime = Annotated[
datetime, PlainSerializer(lambda v: to_utc_aware(v).isoformat(), return_type=str)
]
# Input: aware values are converted to UTC naive, naive is taken as UTC.
UtcNaiveInput = Annotated[Optional[datetime], AfterValidator(as_utc_naive)]
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for User operations."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
@@ -48,8 +48,8 @@ class UserResponse(BaseModel):
language_pref: str
theme_pref: str
active: bool
created_at: datetime
last_login: Optional[datetime] = None
created_at: UtcDateTime
last_login: Optional[UtcDateTime] = None
class UserPasswordChange(BaseModel):
+16 -1
View File
@@ -1,5 +1,7 @@
"""Pydantic schemas for the vision execution response."""
from pydantic import BaseModel
from src.backend.models.api.tipi import UtcDateTime
from pydantic import BaseModel, ConfigDict
class VisionMeasurementResult(BaseModel):
@@ -14,3 +16,16 @@ class VisionExecuteResponse(BaseModel):
vision_result_id: int
engine_version: str
measurements: list[VisionMeasurementResult]
class VisionReferenceImageResponse(BaseModel):
"""One saved reference image, as `GET /api/vision/reference-images` lists it."""
model_config = ConfigDict(from_attributes=True)
id: int
task_id: int
path: str
note: str | None = None
station_id: int | None = None
device_code: str | None = None
acquired_at: UtcDateTime
+2
View File
@@ -1,5 +1,6 @@
"""SQLAlchemy models for TieMeasureFlow."""
from src.backend.models.orm.user import User
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
from src.backend.models.orm.measurement import Measurement
@@ -11,6 +12,7 @@ from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
__all__ = [
"User",
"ApiKey",
"Recipe",
"RecipeVersion",
"RecipeTask",
+25
View File
@@ -0,0 +1,25 @@
"""API key sessions: one row per login, stored as a SHA-256 hash."""
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from src.backend.database import Base
class ApiKey(Base):
"""One API key per login session.
Only the SHA-256 hex digest is stored, never the key. Dates are naive UTC.
"""
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
-1
View File
@@ -32,7 +32,6 @@ class User(Base):
# Status
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
api_key: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
+4 -2
View File
@@ -13,7 +13,8 @@ class VisionResult(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
nullable=False, index=True,
)
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
@@ -48,7 +49,8 @@ class VisionReferenceImage(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
nullable=False, index=True,
)
path: Mapped[str] = mapped_column(String(500), nullable=False)
station_id: Mapped[Optional[int]] = mapped_column(
+26 -3
View File
@@ -1,4 +1,5 @@
"""AI service for parsing technical sheets via OpenRouter."""
import asyncio
import json
import logging
from io import BytesIO
@@ -10,6 +11,17 @@ from src.backend.config import settings
logger = logging.getLogger(__name__)
class AIResponseError(Exception):
"""The AI model's response could not be used: missing fields or invalid JSON.
Distinct from ValueError (raised for our own misconfiguration, e.g. a
missing API key) so the router can tell "we are broken" (400) apart from
"the upstream AI service is broken" (502) instead of both landing on the
same except clause.
"""
SYSTEM_PROMPT = """Sei un assistente specializzato nell'analisi di schede tecniche industriali.
Analizza il testo estratto da una scheda tecnica e identifica i blocchi operativi distinti.
Per ogni blocco, estrai:
@@ -36,7 +48,9 @@ def extract_text_from_pdf(pdf_bytes: bytes) -> str:
async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
text = extract_text_from_pdf(pdf_bytes)
# pdfplumber parses synchronously and can take a while on a big PDF: off
# the event loop, or one upload stalls every other request being served.
text = await asyncio.to_thread(extract_text_from_pdf, pdf_bytes)
if not text.strip():
return []
@@ -62,7 +76,10 @@ async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"].strip()
try:
content = data["choices"][0]["message"]["content"].strip()
except (KeyError, IndexError, TypeError, AttributeError) as e:
raise AIResponseError(f"Risposta del modello AI malformata: {e}") from e
# Strip markdown fences if present
if content.startswith("```"):
@@ -71,4 +88,10 @@ async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
content = content[:-3]
content = content.strip()
return json.loads(content)
try:
return json.loads(content)
except json.JSONDecodeError as e:
# json.JSONDecodeError is itself a ValueError: without this, the
# router's `except ValueError` (meant for our own config errors)
# would misreport a broken upstream response as our fault (400).
raise AIResponseError(f"Risposta del modello AI non e' JSON valido: {e}") from e
+78 -25
View File
@@ -1,12 +1,16 @@
"""Authentication service - password hashing, API key management."""
import hashlib
import secrets
from datetime import datetime
from datetime import datetime, timedelta
from src.backend.utils.tempo import utcnow
import bcrypt
from fastapi import HTTPException, status
from sqlalchemy import select, update
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.user import User
@@ -27,6 +31,13 @@ def generate_api_key() -> str:
return secrets.token_urlsafe(48) # 64 chars base64
# A precomputed, unused bcrypt hash. When the username does not match any
# user, authenticate_user still runs bcrypt against this instead of
# returning immediately, so "unknown username" costs the same as "wrong
# password" and a caller cannot use response time to enumerate usernames.
_DUMMY_PASSWORD_HASH = "$2b$12$BLtwrR2COyd5W/UWfxp7BOc7OHua9jazo6zZdWOwRD9PV.pyaqKZC"
async def authenticate_user(
db: AsyncSession, username: str, password: str
) -> User | None:
@@ -35,7 +46,10 @@ async def authenticate_user(
select(User).where(User.username == username, User.active == True)
)
user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash):
if user is None:
verify_password(password, _DUMMY_PASSWORD_HASH)
return None
if not verify_password(password, user.password_hash):
return None
return user
@@ -48,8 +62,8 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
of them because it is the same question every time - who is this, and are they
allowed to say yes.
Uses authenticate_user rather than a full login: logging in would rotate the
supervisor's API key and knock out whatever session they have open elsewhere.
Uses authenticate_user rather than a full login: logging in would issue a
needless API key for the supervisor.
"""
supervisor = await authenticate_user(db, username, password)
if supervisor is None:
@@ -65,24 +79,67 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
return supervisor
async def login_user(db: AsyncSession, user: User) -> str:
"""Generate API key and update last_login for user."""
api_key = generate_api_key()
def hash_api_key(key: str) -> str:
"""SHA-256 hex digest of an API key; this is all the DB ever stores."""
return hashlib.sha256(key.encode("utf-8")).hexdigest()
async def issue_api_key(db: AsyncSession, user: User) -> str:
"""Create a new session key for the user and return it in clear (once)."""
key = generate_api_key()
now = utcnow()
# Housekeeping: drop this user's expired keys so the table does not grow forever.
await db.execute(
update(User)
.where(User.id == user.id)
.values(api_key=api_key, last_login=datetime.utcnow())
delete(ApiKey).where(ApiKey.user_id == user.id, ApiKey.expires_at <= now)
)
db.add(
ApiKey(
user_id=user.id,
key_hash=hash_api_key(key),
created_at=now,
expires_at=now + timedelta(hours=settings.api_key_ttl_hours),
last_used_at=now,
)
)
await db.flush()
return key
async def revoke_all_keys(db: AsyncSession, user_id: int) -> None:
"""Invalidate every session key of a user."""
await db.execute(delete(ApiKey).where(ApiKey.user_id == user_id))
await db.flush()
async def login_user(db: AsyncSession, user: User) -> str:
"""Issue a new API key for this login and update last_login.
One key per login: the same account used from several stations at once
(#25) keeps every station working, because no login revokes another's key.
"""
api_key = await issue_api_key(db, user)
await db.execute(
update(User).where(User.id == user.id).values(last_login=utcnow())
)
await db.flush()
return api_key
async def logout_user(db: AsyncSession, user: User) -> None:
"""Invalidate user's API key."""
await db.execute(
update(User).where(User.id == user.id).values(api_key=None)
)
await db.flush()
async def logout_user(
db: AsyncSession, user: User, key: str | None = None, all_sessions: bool = False
) -> None:
"""Log out. Deletes only the key of this session; all_sessions=True
deletes every key of the user."""
if all_sessions:
await revoke_all_keys(db, user.id)
return
if key:
await db.execute(
delete(ApiKey).where(
ApiKey.user_id == user.id, ApiKey.key_hash == hash_api_key(key)
)
)
await db.flush()
async def create_user(
@@ -113,11 +170,7 @@ async def create_user(
return user
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
"""Regenerate API key for a user."""
new_key = generate_api_key()
await db.execute(
update(User).where(User.id == user_id).values(api_key=new_key)
)
await db.flush()
return new_key
async def regenerate_api_key(db: AsyncSession, user: User) -> str:
"""Revoke every key of the user and issue a fresh one."""
await revoke_all_keys(db, user.id)
return await issue_api_key(db, user)
+160 -1
View File
@@ -1,18 +1,54 @@
"""Measurement service - pass/fail calculation, data storage."""
import math
from datetime import datetime
from src.backend.utils.tempo import utcnow
from decimal import Decimal
from typing import TYPE_CHECKING
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionEvent
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import (
MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask,
)
from src.backend.models.orm.user import User
if TYPE_CHECKING:
from src.backend.models.orm.production import ProductionRun
# What the Measurement.value column (DECIMAL(12,6)) can actually hold.
_MAX_ABS_VALUE = 1_000_000
def _validate_measured_value(value: float) -> None:
"""Refuse a value that would corrupt the verdict or the INSERT.
NaN makes every tolerance comparison false, so a bad reading silently
comes out as "pass"; NaN and Infinity then both fail the DECIMAL(12,6)
column at insert time with an IntegrityError the operator never asked
for. Raised as a plain HTTPException with a string detail rather than as
a pydantic Field constraint on the request model: a Field rejecting NaN
(via allow_inf_nan or a gt/lt bound) makes pydantic echo that same NaN
back in the validation error's "input", and FastAPI's default error
response then crashes with a 500 trying to JSON-encode it
(Starlette's JSONResponse calls json.dumps(..., allow_nan=False)).
"""
if not math.isfinite(value):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Measured value must be a finite number (no NaN/Infinity)",
)
if abs(value) >= _MAX_ABS_VALUE:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Measured value out of range: |value| must be < {_MAX_ABS_VALUE}",
)
def calculate_pass_fail(
value: float, subtask: RecipeSubtask
@@ -25,7 +61,12 @@ def calculate_pass_fail(
- If value is outside UTL/LTL -> 'fail'
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
- Otherwise -> 'pass'
The value is rounded to 6 decimals first - the precision the DECIMAL(12,6)
column keeps - so the verdict is computed on the same number that ends up
stored, and a value like 10.5000004 does not fail against a 10.5 limit.
"""
value = round(value, 6)
deviation = None
if subtask.nominal is not None:
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
@@ -202,12 +243,108 @@ async def task_progress(
return progress
async def current_cycle_measurements(
db: AsyncSession, run_id: int, task_id: int,
) -> list[dict]:
"""The quotes of a task already measured in the cycle that is under way.
A reload, a timer redirect or an auto-logout throws away what the page held in
memory; the operator would measure the same quotes again and the duplicates
would reach the SPC statistics. The cycle needs no column of its own: it starts
at the latest event of the run that opens one for this task - the run start, a
cycle_completed, or a remeasure of this task.
If a task_measured of this task follows that boundary the task is already
closed in this cycle and there is nothing to restore. Where a quote was measured
more than once, the last reading stands.
"""
boundary = (await db.execute(
select(ProductionEvent.id, ProductionEvent.created_at)
.where(
ProductionEvent.run_id == run_id,
(ProductionEvent.event_type.in_(("start", "cycle_completed")))
| (
(ProductionEvent.event_type == "remeasure")
& (ProductionEvent.task_id == task_id)
),
)
.order_by(ProductionEvent.id.desc())
.limit(1)
)).first()
event_filter = [ProductionEvent.run_id == run_id]
measurement_filter = [Measurement.production_run_id == run_id]
if boundary is not None:
boundary_id, boundary_at = boundary
event_filter.append(ProductionEvent.id > boundary_id)
measurement_filter.append(Measurement.measured_at > boundary_at)
closed = (await db.execute(
select(ProductionEvent.id).where(
*event_filter,
ProductionEvent.event_type == "task_measured",
ProductionEvent.task_id == task_id,
).limit(1)
)).first()
if closed is not None:
return []
rows = (await db.execute(
select(Measurement)
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id)
.where(*measurement_filter, RecipeSubtask.task_id == task_id)
.order_by(Measurement.measured_at, Measurement.id)
)).scalars().all()
latest: dict[int, Measurement] = {}
for m in rows:
latest[m.subtask_id] = m
return [
{
"subtask_id": m.subtask_id,
"value": float(m.value),
"pass_fail": m.pass_fail,
"measured_at": m.measured_at,
}
for m in sorted(latest.values(), key=lambda m: (m.measured_at, m.id))
]
async def validate_run_for_version(
db: AsyncSession, run_id: int, version_id: int,
) -> "ProductionRun":
"""The run a measurement is being filed against, checked for coherence.
Three things have to hold before a value goes in against a production: the
run has to exist, it has to belong to the same recipe as the version being
measured (a run started for another product must not collect another
product's history), and it has to be in a state that accepts measurements
(not closed, not paused). Shared with vision_service, which files camera
measurements against a run the same way a manual reading does.
"""
# Local import: production_service imports this module at module scope,
# so importing it back here at module scope would be circular.
from src.backend.services import production_service
run = await production_service.get_run(db, run_id) # 404 if missing
recipe = await recipe_of_version(db, version_id)
if recipe is None or run.recipe_id != recipe.id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Production run belongs to a different recipe than this version",
)
production_service._require_measurable(run) # 409 if closed or paused
return run
async def authorise_measurement(
db: AsyncSession, measurement: Measurement, supervisor: User,
) -> Measurement:
"""Let an out-of-tolerance value stand, on the supervisor's name."""
measurement.supervisor_id = supervisor.id
measurement.authorised_at = datetime.now()
measurement.authorised_at = utcnow()
await db.flush()
await db.refresh(measurement)
return measurement
@@ -236,6 +373,8 @@ async def save_measurement(
the gate models "moving on" between measurements taken one after another,
which does not exist inside a single camera acquisition.
"""
_validate_measured_value(value)
# Get subtask for tolerance values
result = await db.execute(
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
@@ -244,6 +383,22 @@ async def save_measurement(
if subtask is None:
raise ValueError(f"Subtask {subtask_id} not found")
# The quote has to belong to a task of the declared version - otherwise the
# tolerances just read off `subtask` are not the ones this version's operator
# was shown, and pass/fail is computed against the wrong recipe.
task_row = await db.execute(
select(RecipeTask.version_id).where(RecipeTask.id == subtask.task_id)
)
subtask_version_id = task_row.scalar_one_or_none()
if subtask_version_id != version_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Subtask does not belong to the given version",
)
if production_run_id is not None:
await validate_run_for_version(db, production_run_id, version_id)
recipe = await recipe_of_version(db, version_id)
_enforce_recipe_rules(recipe, lot_number, serial_number, input_method)
@@ -263,6 +418,10 @@ async def save_measurement(
),
)
# Rounded once, here, so the stored value is exactly the one the verdict
# above was computed on - calculate_pass_fail rounds internally too, but
# matching it here keeps the persisted row from disagreeing with itself.
value = round(value, 6)
pass_fail, deviation = calculate_pass_fail(value, subtask)
measurement = Measurement(
@@ -9,9 +9,12 @@ The CSV uses the delimiters configured in system_settings, the same ones the man
export honours, so a shop floor set to the Italian convention (';' and ',') gets
files that open correctly in their Excel.
"""
import asyncio
import csv
import io
import logging
from datetime import datetime
from src.backend.utils.tempo import iso_utc, to_local, utcnow
from pathlib import Path
from sqlalchemy import select, update
@@ -25,10 +28,38 @@ from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask
logger = logging.getLogger(__name__)
# Written under the upload directory: it is the volume that is already mounted and
# backed up, so the files survive a container being replaced.
EXPORT_SUBDIR = "statistics"
# `csv.writer` requires a 1-character delimiter. PUT /api/settings/ validates this
# going forward (gitea#23), but a value written before that guard existed must not
# be able to make close_run fail: fall back and log instead of raising.
_VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
_FALLBACK_CSV_DELIMITER = ";"
# A cell starting with one of these (after leading spaces) is read as a formula
# by Excel/LibreOffice when the CSV is opened — CSV/formula injection (OWASP).
_DANGEROUS_CSV_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
def neutralise_csv_cell(value: str) -> str:
"""Defuse a formula-injection payload in a text CSV cell.
Prepends a single quote when the value, stripped of leading spaces, starts
with =, +, -, @, TAB or CR: this keeps spreadsheet software from evaluating
it as a formula while the value stays valid, literal CSV text. Only for
free-text fields (lot, serial, descriptions, names, codes) — never call
this on numeric cells, the quote would make them text.
"""
stripped = value.lstrip(" ")
if stripped and stripped[0] in _DANGEROUS_CSV_PREFIXES:
return "'" + value
return value
HEADER = [
"production_run_id",
"station_code",
@@ -75,8 +106,17 @@ async def _csv_format(db: AsyncSession) -> tuple[str, str]:
)
)
decimal = decimal_row.scalar_one_or_none()
delimiter_value = delimiter.setting_value if delimiter else ","
if delimiter_value not in _VALID_CSV_DELIMITERS:
logger.warning(
"csv_delimiter non valido nel DB (%r): uso %r",
delimiter_value, _FALLBACK_CSV_DELIMITER,
)
delimiter_value = _FALLBACK_CSV_DELIMITER
return (
delimiter.setting_value if delimiter else ",",
delimiter_value,
decimal.setting_value if decimal else ".",
)
@@ -109,15 +149,15 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
for measurement, subtask in rows:
writer.writerow([
run.id,
station.code if station else "",
recipe.code if recipe else "",
neutralise_csv_cell(station.code) if station else "",
neutralise_csv_cell(recipe.code) if recipe else "",
measurement.version_id,
measurement.lot_number or "",
measurement.serial_number or "",
neutralise_csv_cell(measurement.lot_number or ""),
neutralise_csv_cell(measurement.serial_number or ""),
measurement.id,
measurement.subtask_id,
subtask.marker_number if subtask else "",
subtask.description if subtask else "",
neutralise_csv_cell(subtask.description) if subtask else "",
number(subtask.nominal) if subtask else "",
number(measurement.value),
number(measurement.deviation),
@@ -130,9 +170,9 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
measurement.input_method,
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
measurement.measured_by,
measurement.measured_at.isoformat() if measurement.measured_at else "",
iso_utc(measurement.measured_at),
measurement.supervisor_id or "",
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
iso_utc(measurement.authorised_at),
])
return output.getvalue()
@@ -152,9 +192,13 @@ async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | N
directory = Path(settings.upload_path) / EXPORT_SUBDIR
directory.mkdir(parents=True, exist_ok=True)
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
stamp = to_local(run.closed_at or utcnow()).strftime("%Y%m%d_%H%M%S")
filename = f"production_{run.id}_{stamp}.csv"
(directory / filename).write_text(content, encoding="utf-8-sig")
# A large run's file can take a moment to write: off the event loop, so
# closing one run does not stall every other request being served.
await asyncio.to_thread(
(directory / filename).write_text, content, encoding="utf-8-sig"
)
# utf-8-sig: the BOM is what makes Excel read accented characters correctly,
# and these files are opened in Excel on the shop floor.
+33 -3
View File
@@ -14,6 +14,7 @@ the countdown would be off by the UTC offset. The number is the contract; the
timestamp is only there for the audit trail.
"""
from datetime import datetime, timedelta
from src.backend.utils.tempo import utcnow
from typing import Optional
from fastapi import HTTPException, status
@@ -23,7 +24,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.api.production import ProductionRunCreate
from src.backend.models.orm.production import ProductionEvent, ProductionRun
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.models.orm.task import MEASURING_TASK_TYPES, RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import (
@@ -34,10 +35,10 @@ from src.backend.services import (
def _now() -> datetime:
"""Single source of time for runs.
Naive local time, matching what the database writes for ``func.now()`` defaults,
Naive UTC, matching what the database writes for ``func.now()`` defaults,
so a run's started_at and its deadline are comparable.
"""
return datetime.now()
return utcnow()
# ---------------------------------------------------------------------------
@@ -106,6 +107,29 @@ async def get_station_by_code(db: AsyncSession, code: str) -> Station:
return station
async def _require_recipe_assigned_to_station(
db: AsyncSession, station_id: int, recipe_id: int,
) -> None:
"""A configured station only runs what it is assigned; an unconfigured one is free.
Mirrors station_service.list_station_recipes, the read side an operator's
recipe list is already filtered by: a station with at least one assignment
restricts to those recipes, one with none has not been set up to filter at
all and stays open to any active recipe.
"""
result = await db.execute(
select(StationRecipeAssignment.recipe_id).where(
StationRecipeAssignment.station_id == station_id,
)
)
assigned_ids = set(result.scalars().all())
if assigned_ids and recipe_id not in assigned_ids:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This recipe is not assigned to this station",
)
async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
"""Ids of the measurement tasks of a version, in the order they are executed.
@@ -199,6 +223,12 @@ async def open_run(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
)
if not recipe.active:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot start a production run with an inactive recipe",
)
await _require_recipe_assigned_to_station(db, station.id, recipe.id)
if data.version_id is not None:
version_row = await db.execute(
+51 -6
View File
@@ -12,6 +12,7 @@ from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.setting import RecipeVersionAudit
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.user import User
from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
@@ -34,13 +35,34 @@ HEADER_FIELDS = (
)
# image_path and measurement_interval_minutes are nullable columns where the
# caller may deliberately want to clear the value (send null), not merely
# leave it untouched by not mentioning the field at all. A plain
# "getattr(...) is not None" check cannot tell those two cases apart -
# once set, neither field could ever be cleared again (#37.3). The other
# header fields keep that simpler check: booleans are always sent by the
# client (never null) and clearing name/description to null is not a case
# this endpoint needs to support.
_NULLABLE_HEADER_FIELDS = ("image_path", "measurement_interval_minutes")
def _header_fields(data: RecipeUpdate) -> dict:
"""The header columns the caller actually asked to change."""
return {
field: getattr(data, field)
for field in HEADER_FIELDS
if getattr(data, field) is not None
}
"""The header columns the caller actually asked to change.
For image_path/measurement_interval_minutes, "asked to change" includes
explicitly setting it to null - told apart from "field absent from the
request" via model_fields_set, since RecipeUpdate defaults both to None.
"""
result = {}
for field in HEADER_FIELDS:
if field in _NULLABLE_HEADER_FIELDS:
if field in data.model_fields_set:
result[field] = getattr(data, field)
else:
value = getattr(data, field)
if value is not None:
result[field] = value
return result
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
@@ -97,6 +119,28 @@ async def _copy_tasks_to_version(
db.add(new_task)
await db.flush() # get new_task.id
# Reference images (#38.6): the copied task points at the same files
# on disk as the source - same `path`, same acquisition metadata. A
# graph composed against a reference must keep working after the
# recipe is next edited, exactly like task_type/vision_json above.
# The file itself is only ever removed once no row references its
# path any more (see vision_service.file_still_referenced).
ref_images = (await db.execute(
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
)).scalars().all()
for ref in ref_images:
db.add(VisionReferenceImage(
task_id=new_task.id,
path=ref.path,
station_id=ref.station_id,
device_code=ref.device_code,
calibration_snapshot=ref.calibration_snapshot,
engine_version=ref.engine_version,
note=ref.note,
expected_json=ref.expected_json,
acquired_at=ref.acquired_at,
))
for sub in task.subtasks:
new_sub = RecipeSubtask(
task_id=new_task.id,
@@ -425,6 +469,7 @@ async def update_current_version(
default_task = RecipeTask(
version_id=current.id,
order_index=0,
task_type="drawing",
title="Technical Drawing",
file_path=data.file_path,
file_type=data.file_type or (
+30 -14
View File
@@ -1,6 +1,8 @@
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
import asyncio
import base64
from datetime import datetime
from src.backend.utils.tempo import to_local, utcnow
from pathlib import Path
import plotly.graph_objects as go
@@ -31,6 +33,11 @@ _jinja_env = Environment(
)
def _giorno_locale(dt: datetime) -> str:
"""UTC naive filter bound -> local calendar day (YYYY-MM-DD)."""
return to_local(dt).strftime("%Y-%m-%d")
async def _query_measurements(
db: AsyncSession,
recipe_id: int,
@@ -204,6 +211,11 @@ def _generate_control_chart_svg(chart_data, subtask_info: dict | None) -> str:
return svg_bytes.decode("utf-8")
def _render_pdf(html_content: str) -> bytes:
"""Blocking WeasyPrint render — always call via asyncio.to_thread."""
return HTML(string=html_content).write_pdf()
def _generate_histogram_svg(hist_data, subtask_info: dict | None) -> str:
"""Generate histogram SVG using Plotly + Kaleido."""
if not hist_data.bins:
@@ -300,9 +312,13 @@ async def generate_spc_report(
)
histogram = compute_histogram(values)
# 3. Generate SVG charts
control_chart_svg = _generate_control_chart_svg(control_chart, subtask_info)
histogram_svg = _generate_histogram_svg(histogram, subtask_info)
# 3. Generate SVG charts (kaleido shells out and blocks: off the event loop)
control_chart_svg = await asyncio.to_thread(
_generate_control_chart_svg, control_chart, subtask_info
)
histogram_svg = await asyncio.to_thread(
_generate_histogram_svg, histogram, subtask_info
)
# 4. Get company info and recipe info
company = await _get_company_info(db)
@@ -313,9 +329,9 @@ async def generate_spc_report(
if version_id:
filters_desc.append(f"Version: {version_id}")
if date_from:
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
filters_desc.append(f"From: {_giorno_locale(date_from)}")
if date_to:
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
filters_desc.append(f"To: {_giorno_locale(date_to)}")
if lot_number:
filters_desc.append(f"Lot: {lot_number}")
if serial_number:
@@ -332,12 +348,12 @@ async def generate_spc_report(
control_chart_svg=control_chart_svg,
histogram_svg=histogram_svg,
filters_desc=filters_desc,
generated_at=datetime.now(),
generated_at=to_local(utcnow()),
n_measurements=len(measurements),
)
# 7. Convert HTML to PDF
pdf_bytes = HTML(string=html_content).write_pdf()
# 7. Convert HTML to PDF (WeasyPrint blocks: off the event loop)
pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
return pdf_bytes
@@ -385,7 +401,7 @@ async def generate_measurement_report(
"value": f"{float(m.value):.4f}",
"unit": st_info.get("unit", "mm"),
"pass_fail": m.pass_fail,
"measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"),
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
"lot_number": m.lot_number or "—",
"serial_number": m.serial_number or "—",
@@ -404,9 +420,9 @@ async def generate_measurement_report(
if version_id:
filters_desc.append(f"Version: {version_id}")
if date_from:
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
filters_desc.append(f"From: {_giorno_locale(date_from)}")
if date_to:
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
filters_desc.append(f"To: {_giorno_locale(date_to)}")
if lot_number:
filters_desc.append(f"Lot: {lot_number}")
if serial_number:
@@ -420,10 +436,10 @@ async def generate_measurement_report(
rows=rows,
summary=summary,
filters_desc=filters_desc,
generated_at=datetime.now(),
generated_at=to_local(utcnow()),
n_measurements=len(measurements),
)
# 8. Convert to PDF
pdf_bytes = HTML(string=html_content).write_pdf()
# 8. Convert to PDF (WeasyPrint blocks: off the event loop)
pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
return pdf_bytes
+52 -24
View File
@@ -55,6 +55,27 @@ def compute_summary(
)
def _sigma_within(values: list[float]) -> float | None:
"""Estimate within-subgroup sigma from the moving range of individuals.
sigma_within = MRbar / d2, with d2 = 1.128 (moving range of consecutive
pairs, i.e. subgroup size 2). `values` must be in chronological order
(the order in which measurements arrived) — the moving range is not
meaningful on a re-sorted series.
Returns None when fewer than 2 values are available, or when MRbar == 0
(no variation between consecutive points: the index would be undefined).
"""
n = len(values)
if n < 2:
return None
moving_ranges = [abs(values[i] - values[i - 1]) for i in range(1, n)]
mr_bar = stats.mean(moving_ranges)
if mr_bar == 0:
return None
return mr_bar / 1.128
def compute_capability(
values: list[float],
utl: float | None,
@@ -63,12 +84,18 @@ def compute_capability(
) -> CapabilityData:
"""Compute capability indices Cp, Cpk, Pp, Ppk.
`values` must be in chronological order (arrival order), since Cp/Cpk are
derived from the moving range between consecutive points:
- sigma_within = MRbar / 1.128 (MRbar = mean of |x_i - x_(i-1)|)
- Cp = (UTL - LTL) / (6 * sigma_within)
- Cpk = min((UTL - mean) / (3 * sigma), (mean - LTL) / (3 * sigma))
- Pp/Ppk: same formulas using population std dev (same data, no subgrouping)
- Cpk = min((UTL - mean) / (3 * sigma_within), (mean - LTL) / (3 * sigma_within))
- Pp/Ppk: same formulas using the overall sample std dev (n-1, `stats.stdev`)
of the whole series — this is the "process performance" pair and is
allowed to be wider than Cp/Cpk when there is drift between subgroups.
Args:
values: Measured values.
values: Measured values, in chronological order.
utl: Upper Tolerance Limit (None if not defined).
ltl: Lower Tolerance Limit (None if not defined).
nominal: Nominal value (None if not defined).
@@ -81,39 +108,39 @@ def compute_capability(
return CapabilityData(
cp=None, cpk=None, pp=None, ppk=None,
mean=values[0] if n == 1 else 0.0,
std_dev=0.0, n=n,
std_dev=0.0, std_dev_within=None, n=n,
utl=utl, ltl=ltl, nominal=nominal,
)
mean = stats.mean(values)
# Population std dev for Pp/Ppk
std_dev_pop = stats.pstdev(values)
# Sample std dev for Cp/Cpk
# Overall sample std dev (n-1) for Pp/Ppk
std_dev_sample = stats.stdev(values)
# Within-subgroup sigma (moving range) for Cp/Cpk
sigma_within = _sigma_within(values)
cp = cpk = pp = ppk = None
if utl is not None and ltl is not None and std_dev_sample > 0:
cp = round((utl - ltl) / (6 * std_dev_sample), 4)
if sigma_within is not None:
if utl is not None and ltl is not None:
cp = round((utl - ltl) / (6 * sigma_within), 4)
if std_dev_sample > 0:
cpk_values = []
if utl is not None:
cpk_values.append((utl - mean) / (3 * std_dev_sample))
cpk_values.append((utl - mean) / (3 * sigma_within))
if ltl is not None:
cpk_values.append((mean - ltl) / (3 * std_dev_sample))
cpk_values.append((mean - ltl) / (3 * sigma_within))
if cpk_values:
cpk = round(min(cpk_values), 4)
if utl is not None and ltl is not None and std_dev_pop > 0:
pp = round((utl - ltl) / (6 * std_dev_pop), 4)
if std_dev_sample > 0:
if utl is not None and ltl is not None:
pp = round((utl - ltl) / (6 * std_dev_sample), 4)
if std_dev_pop > 0:
ppk_values = []
if utl is not None:
ppk_values.append((utl - mean) / (3 * std_dev_pop))
ppk_values.append((utl - mean) / (3 * std_dev_sample))
if ltl is not None:
ppk_values.append((mean - ltl) / (3 * std_dev_pop))
ppk_values.append((mean - ltl) / (3 * std_dev_sample))
if ppk_values:
ppk = round(min(ppk_values), 4)
@@ -121,6 +148,7 @@ def compute_capability(
cp=cp, cpk=cpk, pp=pp, ppk=ppk,
mean=round(mean, 6),
std_dev=round(std_dev_sample, 6),
std_dev_within=round(sigma_within, 6) if sigma_within is not None else None,
n=n,
utl=utl, ltl=ltl, nominal=nominal,
)
@@ -135,10 +163,12 @@ def compute_control_chart(
ltl: float | None,
nominal: float | None,
) -> ControlChartData:
"""Compute control chart data with UCL/LCL and out-of-control detection.
"""Compute control chart data (individuals chart) with UCL/LCL and
out-of-control detection.
UCL = mean + 3*sigma
LCL = mean - 3*sigma
UCL = mean + 3*sigma_within = mean + 2.66*MRbar
LCL = mean - 3*sigma_within = mean - 2.66*MRbar
with sigma_within = MRbar/1.128 (see `_sigma_within`).
Out-of-control: points outside UCL/LCL.
Args:
@@ -160,10 +190,8 @@ def compute_control_chart(
mean = stats.mean(values)
if n >= 2:
sigma = stats.stdev(values)
else:
sigma = 0.0
sigma_within = _sigma_within(values)
sigma = sigma_within if sigma_within is not None else 0.0
ucl = mean + 3 * sigma
lcl = mean - 3 * sigma
+164 -18
View File
@@ -6,6 +6,7 @@ to the worker over HTTP and stays ignorant of how a number was produced.
from __future__ import annotations
import json
import math
from pathlib import Path
from uuid import uuid4
@@ -16,22 +17,48 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
from src.backend.services import measurement_service
# Worker responses whose status and reason are forwarded as-is rather than
# flattened into a generic 502 - see `_call_worker`.
_FORWARDED_WORKER_STATUSES = frozenset({
status.HTTP_422_UNPROCESSABLE_CONTENT,
status.HTTP_503_SERVICE_UNAVAILABLE,
status.HTTP_504_GATEWAY_TIMEOUT,
})
def _is_measurable(value: object) -> bool:
"""A worker output counts as a quote only if it is a finite, non-bool number.
None (absent, or explicitly null in a malformed response), a bool (a
subclass of int), and NaN/inf all fail this - each one would otherwise
reach `float()` and either crash with a bare TypeError or silently save a
value nobody could have measured.
"""
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(value)
)
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
"""The one place that talks to the worker; the tests replace it.
The worker's fault must not become the API's fault (spec's edge-case
table: "il worker isola il guasto, l'API resta in piedi, il task
riporta l'errore"). A stopped worker raises `httpx.ConnectError`; a job
past the timeout raises `httpx.ReadTimeout` - both would otherwise
propagate past this function and surface to the operator as a naked
500. The response shape is validated here too: a proxy answering 200
with something that is not the worker's JSON contract must not turn
into a bare KeyError further down.
riporta l'errore"). httpx.TimeoutException (ConnectTimeout, ReadTimeout,
WriteTimeout, PoolTimeout) becomes 504; every other transport failure
(ConnectError, ReadError, RemoteProtocolError when the worker dies
mid-response, ...) becomes 502 - both would otherwise propagate past
this function and surface to the operator as a naked 500. The response
shape is validated here too: a proxy answering 200 with something that
is not the worker's JSON contract must not turn into a bare KeyError
further down.
"""
try:
async with httpx.AsyncClient(timeout=120.0) as client:
@@ -40,16 +67,29 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
files={"image": ("frame.png", image_bytes, "image/png")},
data={"graph": json.dumps(graph)},
)
except httpx.ConnectError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc}",
) from exc
except httpx.ReadTimeout as exc:
except httpx.TimeoutException as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"the vision worker did not answer in time: {exc}",
) from exc
except (httpx.TransportError, httpx.HTTPError) as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc}",
) from exc
# The worker already gave this fault a name - forward its status and
# reason instead of flattening every non-200 into a generic 502: 422 is a
# bad request (undecodable image, malformed graph), 503 is "busy, try
# again" (VISION_MAX_CONCURRENCY exceeded), 504 is its own run timeout
# (VISION_RUN_TIMEOUT_S, always tighter than this client's own 120s so
# the worker answers first).
if response.status_code in _FORWARDED_WORKER_STATUSES:
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text
raise HTTPException(status_code=response.status_code, detail=detail)
if response.status_code != 200:
raise HTTPException(
@@ -93,6 +133,35 @@ async def execute_task(
serial_number: str | None = None,
production_run_id: int | None = None,
) -> tuple[VisionResult, list[Measurement]]:
if task.task_type != "camera_measure":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"task type {task.task_type!r} is not a camera measurement",
)
version = (await db.execute(
select(RecipeVersion).where(RecipeVersion.id == version_id)
)).scalar_one_or_none()
if version is None or not version.is_current:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="this task's recipe version is no longer current",
)
recipe = await measurement_service.recipe_of_version(db, version_id)
if recipe is None or not recipe.active:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="this recipe is not active",
)
if production_run_id is not None:
# 404 if the run does not exist, 422 if it belongs to another recipe,
# 409 if it is closed or paused - same rule a manual reading follows.
await measurement_service.validate_run_for_version(
db, production_run_id, version_id,
)
if not task.vision_json:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
@@ -145,8 +214,13 @@ async def execute_task(
outputs = report["outputs"]
# Every quote or none. A half-measured piece read as a whole one is worse
# than a piece not measured at all.
missing = [q.vision_output for q in quotes if q.vision_output not in outputs]
# than a piece not measured at all. A quote counts as produced only if its
# output is present *and* a finite, non-bool number: the in-process runner
# already drops NaN/inf itself (see src/vision/runner.py), but the worker
# is reached over HTTP - a different implementation of it, or a malformed
# response, could still hand back None or a non-finite float for a mapped
# key instead of omitting it outright.
missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))]
if missing:
failed = ", ".join(f["tool_name"] for f in report["failures"])
raise HTTPException(
@@ -186,6 +260,37 @@ async def execute_task(
return result, saved
def _reference_file_path(reference: VisionReferenceImage) -> Path:
return settings.upload_path / reference.path
def remove_reference_file(reference: VisionReferenceImage) -> None:
"""Delete a reference image's file, if it is still there.
Called by the router only once the row's fate is durable: after a
successful commit when the row was just deleted, or to undo a
`save_reference_image` whose row failed to commit (see both call sites).
`missing_ok` because "already gone" must never be an error here.
"""
_reference_file_path(reference).unlink(missing_ok=True)
async def file_still_referenced(db: AsyncSession, path: str) -> bool:
"""True if some VisionReferenceImage row still points at *path*.
Copy-on-write (#38.6) can leave several rows sharing the same file on
disk (the copy carries over the source's `path` rather than duplicating
the file). The router must call this, after the row being deleted has
been committed, before it calls `remove_reference_file` - otherwise
deleting one task's reference would take the file out from under every
other task that still references it.
"""
result = await db.execute(
select(VisionReferenceImage.id).where(VisionReferenceImage.path == path).limit(1)
)
return result.scalar_one_or_none() is not None
async def save_reference_image(
db: AsyncSession,
task_id: int,
@@ -202,17 +307,22 @@ async def save_reference_image(
`extension` must match the upload's real content type (the router derives
it from `image.content_type`) - writing a jpeg to disk as `<uuid>.png`
would be a lie the filename tells forever.
The file is written only after the row has flushed successfully - a bad
FK or constraint fails at `flush()`, before anything exists on disk, so
there is nothing to clean up. The caller still has to remove the file if
the *commit* that follows fails (`remove_reference_file`, from the
router): a flush is not durable, and an orphaned file from a rolled-back
row is exactly what #38 found.
"""
# `upload_path` is the resolved Path, `upload_dir` the bare setting: the
# rest of the codebase uses the former (see production_export_service).
folder = settings.upload_path / "vision" / "reference" / str(task_id)
folder.mkdir(parents=True, exist_ok=True)
name = f"{uuid4().hex}.{extension}"
(folder / name).write_bytes(image_bytes)
relative_path = str(Path("vision") / "reference" / str(task_id) / name)
reference = VisionReferenceImage(
task_id=task_id,
path=str(Path("vision") / "reference" / str(task_id) / name),
path=relative_path,
note=note,
station_id=station_id,
device_code=device_code,
@@ -220,10 +330,46 @@ async def save_reference_image(
)
db.add(reference)
await db.flush()
folder = _reference_file_path(reference).parent
folder.mkdir(parents=True, exist_ok=True)
_reference_file_path(reference).write_bytes(image_bytes)
await db.refresh(reference)
return reference
async def list_reference_images(
db: AsyncSession, task_id: int,
) -> list[VisionReferenceImage]:
result = await db.execute(
select(VisionReferenceImage)
.where(VisionReferenceImage.task_id == task_id)
.order_by(VisionReferenceImage.acquired_at)
)
return list(result.scalars().all())
async def delete_reference_image(
db: AsyncSession, reference_id: int,
) -> VisionReferenceImage:
"""Flush the row's deletion. The caller (router) removes the file only
once the commit that makes the deletion durable has actually succeeded -
deleting it any earlier would leave the row pointing at a file that is
already gone if the commit then failed.
"""
reference = (await db.execute(
select(VisionReferenceImage).where(VisionReferenceImage.id == reference_id)
)).scalar_one_or_none()
if reference is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Reference image not found",
)
await db.delete(reference)
await db.flush()
return reference
async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dict:
"""Run the graph and save nothing. Composing is not measuring."""
if not task.vision_json:
+4 -4
View File
@@ -40,7 +40,7 @@ from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.access_log import AccessLog
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.services.auth_service import hash_password, generate_api_key
from src.backend.services.auth_service import hash_password, issue_api_key
# ---------------------------------------------------------------------------
# In-memory SQLite engine for tests
@@ -142,7 +142,6 @@ async def _create_user(
active: bool = True,
) -> User:
"""Insert a user into the test DB and return it with an API key set."""
api_key = generate_api_key()
user = User(
username=username,
password_hash=hash_password(password),
@@ -150,13 +149,14 @@ async def _create_user(
roles=roles or [],
is_admin=is_admin,
active=active,
api_key=api_key,
language_pref="en",
theme_pref="light",
)
session.add(user)
await session.flush()
await session.refresh(user)
# Clear key of this test session; the DB only keeps its hash.
user.plain_key = await issue_api_key(session, user)
return user
@@ -278,4 +278,4 @@ async def create_test_recipe(
def auth_headers(user: User) -> dict[str, str]:
"""Return headers dict with the user's API key for authenticated requests."""
return {"X-API-Key": user.api_key}
return {"X-API-Key": user.plain_key}
@@ -0,0 +1,118 @@
"""Unit tests for AccessLogMiddleware (gitea#36 point 6).
- action must be truncated to fit AccessLog.action (String(100)): an
untruncated long path used to fail the INSERT silently (swallowed by the
broad except in dispatch()).
- ip_address must come from the same `_client_ip` the rate limiter uses
(proxy-aware), not the raw `request.client.host`, which behind
Nginx/Flask is always localhost.
These stub out async_session_factory and inspect the compiled INSERT
statement directly, rather than going through a real DB round trip: Core
`insert()` against the test's in-memory SQLite has an unrelated
autoincrement quirk (unrelated to this fix) that makes end-to-end
assertions on the persisted row unreliable here.
"""
from unittest.mock import patch
from starlette.requests import Request
from starlette.responses import Response
from src.backend.api.middleware.logging import AccessLogMiddleware
class _FakeSession:
"""Records the statement passed to execute(); no real DB involved."""
def __init__(self):
self.executed_stmt = None
async def execute(self, stmt):
self.executed_stmt = stmt
async def commit(self):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
def _make_request(path: str, extra_headers: list[tuple[bytes, bytes]] | None = None) -> Request:
scope = {
"type": "http",
"method": "GET",
"path": path,
"query_string": b"",
"headers": extra_headers or [],
"client": ("127.0.0.1", 12345),
}
return Request(scope)
async def test_long_path_action_is_truncated():
fake_session = _FakeSession()
long_path = "/api/files/" + "a" * 200
with patch(
"src.backend.api.middleware.logging.async_session_factory",
return_value=fake_session,
):
middleware = AccessLogMiddleware(app=None)
request = _make_request(long_path)
async def call_next(_req):
return Response(status_code=404)
await middleware.dispatch(request, call_next)
assert fake_session.executed_stmt is not None
params = fake_session.executed_stmt.compile().params
assert len(params["action"]) <= 100
assert params["action"] == f"GET {long_path}"[:100]
async def test_short_path_action_is_unaffected():
fake_session = _FakeSession()
with patch(
"src.backend.api.middleware.logging.async_session_factory",
return_value=fake_session,
):
middleware = AccessLogMiddleware(app=None)
request = _make_request("/api/settings/")
async def call_next(_req):
return Response(status_code=200)
await middleware.dispatch(request, call_next)
params = fake_session.executed_stmt.compile().params
assert params["action"] == "GET /api/settings/"
async def test_ip_address_uses_client_ip_resolution():
"""trusted_proxy_count defaults to 1: the last XFF hop (our own proxy's
add) is trusted, not request.client.host (always the proxy itself)."""
fake_session = _FakeSession()
with patch(
"src.backend.api.middleware.logging.async_session_factory",
return_value=fake_session,
):
middleware = AccessLogMiddleware(app=None)
request = _make_request(
"/api/settings/",
extra_headers=[(b"x-forwarded-for", b"203.0.113.7, 10.0.0.1")],
)
async def call_next(_req):
return Response(status_code=200)
await middleware.dispatch(request, call_next)
params = fake_session.executed_stmt.compile().params
assert params["ip_address"] == "10.0.0.1"
assert params["ip_address"] != "127.0.0.1" # not request.client.host
+67
View File
@@ -0,0 +1,67 @@
"""Tests for ai_service.py error handling (gitea#36 point 7).
A malformed response from the AI model (missing fields, invalid JSON) must
raise the dedicated AIResponseError, not a bare KeyError/JSONDecodeError —
JSONDecodeError in particular is itself a ValueError subclass, so without
this it was caught by the router's `except ValueError` (meant for our own
config errors) and misreported as a 400 instead of a 502.
"""
import json
from unittest.mock import AsyncMock, patch
import pytest
from src.backend.services import ai_service
class _FakeResponse:
def __init__(self, json_data):
self._json_data = json_data
def raise_for_status(self):
pass
def json(self):
return self._json_data
def _patch_openrouter(monkeypatch, response_json: dict):
monkeypatch.setattr(ai_service, "extract_text_from_pdf", lambda _b: "some extracted text")
monkeypatch.setattr(ai_service.settings, "openrouter_api_key", "test-key")
monkeypatch.setattr(ai_service.settings, "openrouter_model", "test-model")
fake_client = AsyncMock()
fake_client.post = AsyncMock(return_value=_FakeResponse(response_json))
fake_client.__aenter__ = AsyncMock(return_value=fake_client)
fake_client.__aexit__ = AsyncMock(return_value=False)
return patch("src.backend.services.ai_service.httpx.AsyncClient", return_value=fake_client)
async def test_missing_choices_key_raises_ai_response_error(monkeypatch):
with _patch_openrouter(monkeypatch, {"no_choices_here": True}):
with pytest.raises(ai_service.AIResponseError):
await ai_service.parse_technical_sheet(b"%PDF-fake")
async def test_invalid_json_content_raises_ai_response_error(monkeypatch):
bad_content = "not valid json {{{"
response_json = {"choices": [{"message": {"content": bad_content}}]}
with _patch_openrouter(monkeypatch, response_json):
with pytest.raises(ai_service.AIResponseError):
await ai_service.parse_technical_sheet(b"%PDF-fake")
async def test_valid_json_content_returns_tasks(monkeypatch):
tasks = [{"title": "MATERIALI", "directive": "Verificare", "description": "..."}]
response_json = {"choices": [{"message": {"content": json.dumps(tasks)}}]}
with _patch_openrouter(monkeypatch, response_json):
result = await ai_service.parse_technical_sheet(b"%PDF-fake")
assert result == tasks
async def test_empty_extracted_text_returns_empty_list(monkeypatch):
"""No AI call at all when the PDF has no extractable text."""
monkeypatch.setattr(ai_service, "extract_text_from_pdf", lambda _b: " ")
result = await ai_service.parse_technical_sheet(b"%PDF-fake")
assert result == []
+196
View File
@@ -78,6 +78,136 @@ class TestLogin:
assert len(api_key) >= 32
class TestLoginKeyPerSession:
"""Each login issues its own key; none revokes another (#25, #41)."""
async def test_two_logins_same_user_get_distinct_valid_keys(
self, client: AsyncClient, db_session
):
await _create_user(db_session, username="twostations", password="TwoStations1")
creds = {"username": "twostations", "password": "TwoStations1"}
resp1 = await client.post("/api/auth/login", json=creds)
resp2 = await client.post("/api/auth/login", json=creds)
k1, k2 = resp1.json()["api_key"], resp2.json()["api_key"]
assert k1 != k2
for k in (k1, k2):
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
assert resp.status_code == 200
async def test_db_stores_hash_never_clear_key(
self, client: AsyncClient, db_session
):
from sqlalchemy import select
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import hash_api_key
await _create_user(db_session, username="hashed", password="HashedPass1")
resp = await client.post(
"/api/auth/login", json={"username": "hashed", "password": "HashedPass1"}
)
key = resp.json()["api_key"]
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
assert hash_api_key(key) in hashes
assert key not in hashes
assert all(len(h) == 64 for h in hashes)
async def test_expired_key_gives_401(self, client: AsyncClient, db_session):
from datetime import timedelta
from sqlalchemy import update
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import utcnow
user = await _create_user(db_session, username="expired", password="ExpiredPass1")
await db_session.execute(
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
)
resp = await client.get("/api/auth/me", headers=auth_headers(user))
assert resp.status_code == 401
assert "expired" in resp.json()["detail"].lower()
async def test_use_renews_expiry_only_after_interval(
self, client: AsyncClient, db_session
):
from datetime import timedelta
from sqlalchemy import select, update
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import utcnow
user = await _create_user(db_session, username="renew", password="RenewPass1")
headers = auth_headers(user)
old = utcnow() - timedelta(hours=10)
# Recent use: no write, expiry untouched.
recent = utcnow() - timedelta(minutes=1)
await db_session.execute(
update(ApiKey).values(expires_at=old + timedelta(hours=24), last_used_at=recent)
)
await client.get("/api/auth/me", headers=headers)
row = (await db_session.execute(select(ApiKey))).scalar_one()
await db_session.refresh(row)
assert row.last_used_at == recent
# Stale last use: expiry is pushed forward.
await db_session.execute(
update(ApiKey).values(last_used_at=utcnow() - timedelta(minutes=10))
)
before = utcnow()
resp = await client.get("/api/auth/me", headers=headers)
assert resp.status_code == 200
await db_session.refresh(row)
assert row.expires_at >= before + timedelta(hours=23, minutes=59)
class TestLogout:
"""Logout deletes only this session's key (#41)."""
async def test_logout_removes_only_own_key(self, client: AsyncClient, db_session):
await _create_user(db_session, username="shared_logout", password="SharedPass1")
creds = {"username": "shared_logout", "password": "SharedPass1"}
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
resp = await client.post("/api/auth/logout", headers={"X-API-Key": k1})
assert resp.status_code == 204
assert (await client.get("/api/auth/me", headers={"X-API-Key": k1})).status_code == 401
assert (await client.get("/api/auth/me", headers={"X-API-Key": k2})).status_code == 200
async def test_logout_all_sessions_removes_every_key(
self, client: AsyncClient, db_session
):
await _create_user(db_session, username="shared_logout_all", password="SharedPass1")
creds = {"username": "shared_logout_all", "password": "SharedPass1"}
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
resp = await client.post(
"/api/auth/logout", params={"all_sessions": "true"},
headers={"X-API-Key": k1},
)
assert resp.status_code == 204
for k in (k1, k2):
assert (await client.get("/api/auth/me", headers={"X-API-Key": k})).status_code == 401
class TestAuthenticateUserTiming:
"""authenticate_user pays the bcrypt cost even for an unknown username (#25)."""
async def test_unknown_username_still_returns_none(self, db_session):
from src.backend.services.auth_service import authenticate_user
result = await authenticate_user(db_session, "no-such-user-at-all", "whatever")
assert result is None
def test_dummy_hash_is_a_valid_bcrypt_hash(self):
from src.backend.services.auth_service import (
_DUMMY_PASSWORD_HASH,
verify_password,
)
assert verify_password(
"tiemeasureflow-dummy-hash-for-timing", _DUMMY_PASSWORD_HASH
)
class TestHealthAndAuth:
"""Health check and auth requirement tests."""
@@ -94,3 +224,69 @@ class TestHealthAndAuth:
resp = await client.get("/api/users")
assert resp.status_code == 401
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
class TestRevokeAllKeys:
"""Password change, deactivation and regenerate invalidate every key (#41)."""
async def _two_keys(self, client, db_session, name):
user = await _create_user(db_session, username=name, password="KeysPass123")
creds = {"username": name, "password": "KeysPass123"}
keys = [
(await client.post("/api/auth/login", json=creds)).json()["api_key"]
for _ in range(2)
]
return user, keys
async def _all_401(self, client, keys):
for k in keys:
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
assert resp.status_code == 401
async def test_password_change_invalidates_all(self, client, db_session, admin_user):
user, keys = await self._two_keys(client, db_session, "pwkeys")
resp = await client.put(
f"/api/users/{user.id}/password",
headers=auth_headers(admin_user), json={"password": "NewSecurePass1"},
)
assert resp.status_code == 200
await self._all_401(client, keys)
async def test_deactivation_invalidates_all(self, client, db_session, admin_user):
user, keys = await self._two_keys(client, db_session, "offkeys")
resp = await client.delete(
f"/api/users/{user.id}", headers=auth_headers(admin_user)
)
assert resp.status_code == 204
await self._all_401(client, keys)
async def test_regenerate_invalidates_old_keys(self, client, db_session, admin_user):
user, keys = await self._two_keys(client, db_session, "regenkeys")
resp = await client.post(
f"/api/users/{user.id}/regenerate-key", headers=auth_headers(admin_user)
)
assert resp.status_code == 200
await self._all_401(client, keys)
new = resp.json()["api_key"]
assert (await client.get("/api/auth/me", headers={"X-API-Key": new})).status_code == 200
class TestExpiredKeyCleanup:
async def test_expired_key_removed_at_next_login(self, client, db_session):
from datetime import timedelta
from sqlalchemy import select, update
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import hash_api_key, utcnow
user = await _create_user(db_session, username="cleanup", password="CleanPass123")
old_hash = hash_api_key(user.plain_key)
await db_session.execute(
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
)
resp = await client.post(
"/api/auth/login", json={"username": "cleanup", "password": "CleanPass123"}
)
assert resp.status_code == 200
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
assert old_hash not in hashes
assert len(hashes) == 1
+50
View File
@@ -0,0 +1,50 @@
"""Tests for Settings' production-secret guard (issue #33, backend part)."""
import pytest
from src.backend.config import Settings, _DEFAULT_DB_PASSWORD, _DEFAULT_SECRET_KEY
_REAL_SECRET_KEY = "a-real-generated-secret-key-not-the-default"
_REAL_DB_PASSWORD = "a-real-generated-db-password-not-the-default"
def _settings(**overrides) -> Settings:
"""Build a Settings instance without reading the real .env file or
inheriting stray SERVER_SECRET_KEY/DB_PASSWORD from the shell env."""
overrides.setdefault("server_secret_key", _DEFAULT_SECRET_KEY)
overrides.setdefault("db_password", _DEFAULT_DB_PASSWORD)
return Settings(_env_file=None, **overrides)
class TestProductionSecretGuard:
def test_dev_env_allows_default_secrets(self):
s = _settings(app_env="dev")
assert s.server_secret_key == _DEFAULT_SECRET_KEY
assert s.db_password == _DEFAULT_DB_PASSWORD
def test_test_env_allows_default_secrets(self):
s = _settings(app_env="test")
assert s.server_secret_key == _DEFAULT_SECRET_KEY
def test_production_env_rejects_default_secret_key(self):
with pytest.raises(RuntimeError, match="SERVER_SECRET_KEY"):
_settings(app_env="production", db_password=_REAL_DB_PASSWORD)
def test_production_env_rejects_default_db_password(self):
with pytest.raises(RuntimeError, match="DB_PASSWORD"):
_settings(app_env="production", server_secret_key=_REAL_SECRET_KEY)
def test_production_env_accepts_real_secrets(self):
s = _settings(
app_env="production",
server_secret_key=_REAL_SECRET_KEY,
db_password=_REAL_DB_PASSWORD,
)
assert s.app_env == "production"
def test_module_level_settings_instance_starts_without_env(self):
"""The already-instantiated `settings` (imported at process start)
must not have raised - this is what "tests/dev start without .env"
requires: APP_ENV defaults to "dev", so the guard never fires here."""
from src.backend.config import settings
assert settings.app_env == "dev"
+172
View File
@@ -0,0 +1,172 @@
"""#40: after a reload, the quotes already measured in the cycle come back.
The cycle has no column of its own: it opens at the latest start, cycle_completed
or remeasure-of-this-task event of the run, and a task_measured of the task after
that boundary means the task is closed in this cycle.
"""
from datetime import datetime, timedelta
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionEvent, ProductionRun
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.tests.conftest import auth_headers, create_test_recipe
T0 = datetime(2026, 10, 1, 8, 0, 0)
async def _setup(db, user):
recipe = await create_test_recipe(db, user.id)
version = (await db.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
task = (await db.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalars().first()
first = (await db.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
second = RecipeSubtask(
task_id=task.id, marker_number=2, description="Quota 2",
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
)
db.add(second)
station = Station(code="ST-CYC", name="St", active=True, created_by=user.id)
db.add(station)
await db.commit()
run = ProductionRun(
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
operator_id=user.id, status="running", active_station_id=station.id,
)
db.add(run)
await db.commit()
return run, version, task, first, second
async def _event(db, run, user, kind, minutes, task_id=None):
db.add(ProductionEvent(
run_id=run.id, event_type=kind, user_id=user.id, task_id=task_id,
created_at=T0 + timedelta(minutes=minutes),
))
await db.commit()
async def _meas(db, run, version, subtask, user, minutes, value=10.0):
db.add(Measurement(
subtask_id=subtask.id, version_id=version.id, measured_by=user.id,
value=value, pass_fail="pass", production_run_id=run.id,
measured_at=T0 + timedelta(minutes=minutes),
))
await db.commit()
async def _get(client, user, run, task):
resp = await client.get(
"/api/measurements/current-cycle", headers=auth_headers(user),
params={"production_run_id": run.id, "task_id": task.id},
)
assert resp.status_code == 200
return resp.json()["measurements"]
async def test_only_second_cycle_after_cycle_completed(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1, 10.1)
await _meas(db_session, run, version, q2, u, 2, 10.2)
await _event(db_session, run, u, "cycle_completed", 3)
await _meas(db_session, run, version, q1, u, 4, 10.3)
rows = await _get(client, u, run, task)
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.3)]
assert rows[0]["pass_fail"] == "pass"
async def test_last_reading_of_a_quote_wins(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1, 10.1)
await _meas(db_session, run, version, q1, u, 2, 10.4)
rows = await _get(client, u, run, task)
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.4)]
async def test_remeasure_of_the_task_empties_the_list(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1)
await _event(db_session, run, u, "remeasure", 2, task_id=task.id)
assert await _get(client, u, run, task) == []
async def test_remeasure_of_another_task_does_not_move_the_boundary(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
other = RecipeTask(
version_id=version.id, title="Altro", task_type=task.task_type, order_index=9,
)
db_session.add(other)
await db_session.commit()
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1)
await _event(db_session, run, u, "remeasure", 2, task_id=other.id)
assert len(await _get(client, u, run, task)) == 1
async def test_task_measured_after_boundary_means_closed(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1)
await _meas(db_session, run, version, q2, u, 2)
await _event(db_session, run, u, "task_measured", 3, task_id=task.id)
assert await _get(client, u, run, task) == []
async def test_task_measured_before_boundary_does_not_close_next_cycle(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _event(db_session, run, u, "task_measured", 1, task_id=task.id)
await _event(db_session, run, u, "cycle_completed", 2)
await _meas(db_session, run, version, q1, u, 3)
assert len(await _get(client, u, run, task)) == 1
async def test_permissions(client: AsyncClient, db_session, measurement_tec_user, maker_user):
run, _v, task, _q1, _q2 = await _setup(db_session, measurement_tec_user)
params = {"production_run_id": run.id, "task_id": task.id}
no_key = await client.get("/api/measurements/current-cycle", params=params)
assert no_key.status_code in (401, 403)
forbidden = await client.get(
"/api/measurements/current-cycle", headers=auth_headers(maker_user), params=params,
)
assert forbidden.status_code == 403
+359 -1
View File
@@ -7,9 +7,13 @@ from unittest.mock import patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers
from src.backend.tests.conftest import auth_headers, create_test_recipe
class TestUploadFile:
@@ -61,6 +65,51 @@ class TestUploadFile:
assert data["file_type"] == "image/jpeg"
assert data["file_size"] > 0
async def test_extension_forced_to_detected_type_ignores_filename(
self, client: AsyncClient, maker_user: User, tmp_path: Path
):
"""gitea#36 point 2: the stored extension follows the detected magic
bytes, not the client-supplied filename — even a misleading one."""
jpeg_bytes = b"\xff\xd8\xff\xe0\x00\x10JFIF" + b"\x00" * 20 + b"\xff\xd9"
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/files/upload",
headers=auth_headers(maker_user),
# Filename claims .txt; content is a real JPEG.
files={"file": ("photo.txt", io.BytesIO(jpeg_bytes), "text/plain")},
)
assert resp.status_code == 200
data = resp.json()
assert data["file_type"] == "image/jpeg"
assert data["file_path"].endswith(".jpg")
async def test_upload_declared_type_lies_about_html_content(
self, client: AsyncClient, maker_user: User, tmp_path: Path
):
"""gitea#36 point 2: a file whose bytes are HTML must be rejected even
when the client declares Content-Type: image/png and names it .png —
this is what previously let a stored file be served back as
text/html by extension and executed by the browser."""
html_bytes = b"<html><body><script>alert(1)</script></body></html>"
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/files/upload",
headers=auth_headers(maker_user),
files={"file": ("x.png", io.BytesIO(html_bytes), "image/png")},
)
assert resp.status_code == 400
assert not any(tmp_path.rglob("x.png"))
async def test_upload_invalid_type(
self, client: AsyncClient, maker_user: User
):
@@ -134,6 +183,79 @@ class TestGetFile:
assert resp.status_code == 404
class TestGetRestrictedFile:
"""gitea#21: GET statistics/* and reports/* need Metrologist/Supervisor/admin."""
async def test_measurement_tec_forbidden_on_statistics(
self, client: AsyncClient, measurement_tec_user: User, tmp_path: Path
):
stats_dir = tmp_path / "statistics"
stats_dir.mkdir()
(stats_dir / "production_1_x.csv").write_text("a,b\n1,2\n")
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.get(
"/api/files/statistics/production_1_x.csv",
headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 403
async def test_metrologist_allowed_on_statistics(
self, client: AsyncClient, metrologist_user: User, tmp_path: Path
):
stats_dir = tmp_path / "statistics"
stats_dir.mkdir()
(stats_dir / "production_1_x.csv").write_text("a,b\n1,2\n")
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.get(
"/api/files/statistics/production_1_x.csv",
headers=auth_headers(metrologist_user),
)
assert resp.status_code == 200
async def test_admin_allowed_on_reports(
self, client: AsyncClient, admin_user: User, tmp_path: Path
):
reports_dir = tmp_path / "reports"
reports_dir.mkdir()
(reports_dir / "spc_1.pdf").write_bytes(b"%PDF-1.4")
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.get(
"/api/files/reports/spc_1.pdf",
headers=auth_headers(admin_user),
)
assert resp.status_code == 200
async def test_maker_unrestricted_path_still_allowed(
self, client: AsyncClient, maker_user: User, tmp_path: Path
):
"""The restriction is scoped to statistics/reports, not every file."""
test_file = tmp_path / "1" / "1" / "drawing.png"
test_file.parent.mkdir(parents=True)
test_file.write_bytes(b"\x89PNG")
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.get(
"/api/files/1/1/drawing.png",
headers=auth_headers(maker_user),
)
assert resp.status_code == 200
class TestDeleteFile:
"""DELETE /api/files/{file_path} tests."""
@@ -155,6 +277,242 @@ class TestDeleteFile:
assert resp.status_code == 204
assert not test_file.exists()
async def test_delete_unreferenced_recipe_image_allowed(
self, client: AsyncClient, maker_user: User, tmp_path: Path
):
"""A file under a recipe/version path but referenced nowhere is safe."""
test_file = tmp_path / "5" / "12" / "images" / "orphan.png"
test_file.parent.mkdir(parents=True)
test_file.write_bytes(b"\x89PNG")
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.delete(
"/api/files/5/12/images/orphan.png",
headers=auth_headers(maker_user),
)
assert resp.status_code == 204
assert not test_file.exists()
class TestDeleteProtectedFolders:
"""gitea#21: statistics/, reports/, logos/ can never be deleted here."""
async def test_maker_delete_statistics_forbidden(
self, client: AsyncClient, maker_user: User, tmp_path: Path
):
stats_dir = tmp_path / "statistics"
stats_dir.mkdir()
test_file = stats_dir / "production_1_x.csv"
test_file.write_text("a,b\n1,2\n")
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.delete(
"/api/files/statistics/production_1_x.csv",
headers=auth_headers(maker_user),
)
assert resp.status_code == 403
assert test_file.exists()
async def test_admin_delete_reports_forbidden(
self, client: AsyncClient, admin_user: User, tmp_path: Path
):
"""Even an admin cannot delete through this route: not just a role gate."""
reports_dir = tmp_path / "reports"
reports_dir.mkdir()
test_file = reports_dir / "spc_1.pdf"
test_file.write_bytes(b"%PDF-1.4")
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.delete(
"/api/files/reports/spc_1.pdf",
headers=auth_headers(admin_user),
)
assert resp.status_code == 403
assert test_file.exists()
async def test_delete_logo_forbidden(
self, client: AsyncClient, maker_user: User, tmp_path: Path
):
"""The logo is only ever replaced via POST /api/settings/logo."""
logos_dir = tmp_path / "logos"
logos_dir.mkdir()
test_file = logos_dir / "company_logo.png"
test_file.write_bytes(b"\x89PNG")
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.delete(
"/api/files/logos/company_logo.png",
headers=auth_headers(maker_user),
)
assert resp.status_code == 403
assert test_file.exists()
class TestDeleteReferencedRecipeFile:
"""gitea#21: a recipe file backing the current version, a historical
version, or measured data must survive a DELETE (409)."""
async def test_current_version_no_measurements_is_allowed(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
tmp_path: Path,
):
"""Still a draft (current version, nothing measured against it yet):
swapping the drawing out is exactly what editing a recipe does."""
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-A")
version = recipe.versions[0]
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalar_one()
task.file_path = "recipe-a/1/drawing.png"
await db_session.commit()
test_file = tmp_path / "recipe-a" / "1" / "drawing.png"
test_file.parent.mkdir(parents=True)
test_file.write_bytes(b"\x89PNG")
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.delete(
"/api/files/recipe-a/1/drawing.png",
headers=auth_headers(maker_user),
)
assert resp.status_code == 204
assert not test_file.exists()
async def test_current_version_with_measurements_is_409(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
tmp_path: Path,
):
"""Once something has been measured against this version, its drawing
must stay: the audit trail needs it to make sense of the numbers."""
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-D")
version = recipe.versions[0]
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalar_one()
task.file_path = "recipe-d/1/drawing.png"
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.task import RecipeSubtask
subtask = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalar_one()
db_session.add(Measurement(
subtask_id=subtask.id,
version_id=version.id,
measured_by=maker_user.id,
value=10.0,
pass_fail="pass",
input_method="manual",
))
await db_session.commit()
test_file = tmp_path / "recipe-d" / "1" / "drawing.png"
test_file.parent.mkdir(parents=True)
test_file.write_bytes(b"\x89PNG")
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.delete(
"/api/files/recipe-d/1/drawing.png",
headers=auth_headers(maker_user),
)
assert resp.status_code == 409
assert test_file.exists()
async def test_noncurrent_version_task_file_is_409(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
tmp_path: Path,
):
"""A file kept only for a superseded version is history, not garbage."""
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-B")
old_version = RecipeVersion(
recipe_id=recipe.id,
version_number=2,
is_current=False,
created_by=maker_user.id,
change_notes="Superseded",
)
db_session.add(old_version)
await db_session.flush()
old_task = RecipeTask(
version_id=old_version.id,
order_index=0,
title="Old drawing",
task_type="drawing",
file_path="recipe-b/2/old-drawing.png",
)
db_session.add(old_task)
await db_session.commit()
test_file = tmp_path / "recipe-b" / "2" / "old-drawing.png"
test_file.parent.mkdir(parents=True)
test_file.write_bytes(b"\x89PNG")
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.delete(
"/api/files/recipe-b/2/old-drawing.png",
headers=auth_headers(maker_user),
)
assert resp.status_code == 409
assert test_file.exists()
async def test_recipe_cover_image_is_409(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
tmp_path: Path,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-C")
recipe.image_path = "recipe-c/cover.png"
await db_session.commit()
test_file = tmp_path / "recipe-c" / "cover.png"
test_file.parent.mkdir(parents=True)
test_file.write_bytes(b"\x89PNG")
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.delete(
"/api/files/recipe-c/cover.png",
headers=auth_headers(maker_user),
)
assert resp.status_code == 409
assert test_file.exists()
class TestFilenameSanitization:
"""Path traversal protection tests."""
+380 -1
View File
@@ -1,12 +1,16 @@
"""Tests for measurements router (/api/measurements)."""
import csv
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.station import Station
from src.backend.models.orm.user import User
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.services import auth_service
from src.backend.tests.conftest import _create_user, auth_headers, create_test_recipe
async def _get_subtask_and_version(
@@ -223,6 +227,281 @@ class TestMeasurementPassFail:
assert resp.json()["pass_fail"] == "fail"
class TestMeasurementValueBounds:
"""value rejects NaN/Infinity and out-of-DECIMAL(12,6)-scale numbers (#20)."""
@pytest.mark.parametrize("bad_value", [1e300, 1_000_000])
async def test_out_of_scale_value_is_422(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
bad_value: float,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": bad_value,
},
)
assert resp.status_code == 422
@pytest.mark.parametrize("raw_token", ["NaN", "Infinity", "-Infinity"])
async def test_nan_and_infinity_are_422(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
raw_token: str,
):
"""NaN/Infinity are not valid JSON: httpx's own encoder refuses to send
them via ``json=``, so the raw body is built by hand here - exactly
what a client that is not httpx could still put on the wire."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
body = (
'{"subtask_id": %d, "version_id": %d, "value": %s}'
% (subtask_id, version_id, raw_token)
)
resp = await client.post(
"/api/measurements/",
headers={**auth_headers(measurement_tec_user), "Content-Type": "application/json"},
content=body,
)
assert resp.status_code == 422
async def test_value_is_rounded_before_the_verdict(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""10.5000004 rounds to 10.5 (=utl) first, so it is not a fail."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.5000004,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["value"] == pytest.approx(10.5)
# 10.5 is > uwl(10.3) but not > utl(10.5): warning, not fail.
assert data["pass_fail"] == "warning"
class TestSaveMeasurementCoherence:
"""save_measurement checks quote/version/run coherence before insert (#5)."""
async def _station(self, db_session, user_id, code):
station = Station(
code=code, name=f"Stazione {code}", active=True, created_by=user_id,
)
db_session.add(station)
await db_session.commit()
await db_session.refresh(station)
return station
async def _supervisor(self, db_session, username):
user = await auth_service.create_user(
db_session, username=username, password="TurnoPwd1",
display_name="Capo Turno", roles=["Supervisor"],
)
await db_session.commit()
return user, "TurnoPwd1"
async def test_quota_of_another_recipe_version_is_422(
self, client: AsyncClient, measurement_tec_user: User, db_session: AsyncSession,
):
recipe_a = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-A")
recipe_b = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-B")
subtask_a, _version_a = await _get_subtask_and_version(
client, measurement_tec_user, recipe_a.id
)
_subtask_b, version_b = await _get_subtask_and_version(
client, measurement_tec_user, recipe_b.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={"subtask_id": subtask_a, "version_id": version_b, "value": 10.0},
)
assert resp.status_code == 422
async def test_measurement_of_other_recipe_in_run_is_422(
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
db_session: AsyncSession,
):
recipe_a = await create_test_recipe(
db_session, measurement_tec_user.id, code="COH-RUNA"
)
recipe_b = await create_test_recipe(
db_session, measurement_tec_user.id, code="COH-RUNB"
)
station = await self._station(db_session, admin_user.id, "ST-COH1")
run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe_a.id},
)).json()
subtask_b, version_b = await _get_subtask_and_version(
client, measurement_tec_user, recipe_b.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_b,
"version_id": version_b,
"value": 10.0,
"production_run_id": run["id"],
},
)
assert resp.status_code == 422
async def test_measurement_on_closed_run_is_409(
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-CLOSED")
station = await self._station(db_session, admin_user.id, "ST-COH2")
run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)).json()
supervisor, password = await self._supervisor(db_session, "capo-coh-close")
await client.post(
f"/api/production-runs/{run['id']}/close",
headers=auth_headers(measurement_tec_user),
json={
"supervisor_username": supervisor.username,
"supervisor_password": password,
},
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
"production_run_id": run["id"],
},
)
assert resp.status_code == 409
async def test_measurement_on_paused_run_is_409(
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-PAUSED")
station = await self._station(db_session, admin_user.id, "ST-COH3")
run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)).json()
supervisor, password = await self._supervisor(db_session, "capo-coh-pause")
await client.post(
f"/api/production-runs/{run['id']}/pause",
headers=auth_headers(measurement_tec_user),
json={
"supervisor_username": supervisor.username,
"supervisor_password": password,
},
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
"production_run_id": run["id"],
},
)
assert resp.status_code == 409
async def test_valid_measurement_in_matching_run_is_200(
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-OK")
station = await self._station(db_session, admin_user.id, "ST-COH4")
run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)).json()
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
"production_run_id": run["id"],
},
)
assert resp.status_code == 200
async def test_measurement_on_unknown_run_is_404(
self, client: AsyncClient, measurement_tec_user: User, db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-404")
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
"production_run_id": 999999,
},
)
assert resp.status_code == 404
class TestListMeasurements:
"""GET /api/measurements/ tests."""
@@ -366,3 +645,103 @@ class TestMeasurementStatistics:
# deviation = 10.2 - nominal(10.0) = 0.2
assert data["deviation"] is not None
assert abs(data["deviation"] - 0.2) < 0.001
class TestExportCsv:
"""GET /api/measurements/export/csv tests (gitea#22, gitea#36 point 3)."""
async def test_admin_without_explicit_role_can_export(
self, client: AsyncClient, db_session: AsyncSession,
):
"""An admin with no MeasurementTec/Metrologist role must not get 403 (#36.3).
admin_user (conftest) already has both roles, which would pass even
without the fix: a bare admin (roles=[]) is the actual regression case.
"""
bare_admin = await _create_user(
db_session, username="bare_admin", roles=[], is_admin=True,
)
resp = await client.get(
"/api/measurements/export/csv",
headers=auth_headers(bare_admin),
)
assert resp.status_code == 200
async def test_maker_without_role_still_forbidden(
self, client: AsyncClient, maker_user: User,
):
"""A plain Maker (no MeasurementTec/Metrologist/admin) still gets 403."""
resp = await client.get(
"/api/measurements/export/csv",
headers=auth_headers(maker_user),
)
assert resp.status_code == 403
async def test_lot_number_formula_is_neutralised(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""A lot_number starting with '=' must not reach the CSV as a live formula (#22)."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
"lot_number": '=HYPERLINK("http://x","y")',
},
)
assert resp.status_code == 200
export_resp = await client.get(
"/api/measurements/export/csv",
headers=auth_headers(measurement_tec_user),
)
assert export_resp.status_code == 200
rows = list(csv.reader(export_resp.text.splitlines()))
header, row = rows[0], rows[1]
lot_cell = row[header.index("lot_number")]
# Quoted with a leading ' so a spreadsheet reads it as literal text,
# not the raw formula that would otherwise be live in Excel/LibreOffice.
assert lot_cell == '\'=HYPERLINK("http://x","y")'
async def test_negative_value_stays_numeric(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Neutralisation must never touch numeric cells like the measured value."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": -1.25,
},
)
assert resp.status_code == 200
export_resp = await client.get(
"/api/measurements/export/csv",
headers=auth_headers(measurement_tec_user),
)
assert export_resp.status_code == 200
rows = list(csv.reader(export_resp.text.splitlines()))
header, row = rows[0], rows[1]
value_cell = row[header.index("value")]
assert value_cell == "-1.250000"
+133
View File
@@ -0,0 +1,133 @@
"""Tests for the Alembic migration chain, and specifically for
000_base_schema.py (issue #6): a genuinely empty database has no starting
point before it, since 001-013 all assume tables 001 was written against
already exist.
These run the real `alembic upgrade head` (via `alembic.command`), not the
async app engine: `migrations/env.py` builds its own engine from
`settings.database_url`, so `Settings.database_url` is patched at the class
level (a plain instance monkeypatch cannot override a `@property`) to point
at a throwaway SQLite file for the duration of each test.
"""
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect as sa_inspect, text
from src.backend.config import Settings
from src.backend.database import Base
_ALEMBIC_INI = (
Path(__file__).resolve().parents[1] / "migrations" / "alembic.ini"
)
@pytest.fixture
def sqlite_database_url(tmp_path, monkeypatch):
"""Point settings.database_url (and therefore migrations/env.py) at a
throwaway SQLite file, and return its async URL."""
db_path = tmp_path / "migration_test.db"
async_url = f"sqlite+aiosqlite:///{db_path}"
monkeypatch.setattr(Settings, "database_url", property(lambda self: async_url))
return db_path, async_url
def _alembic_config() -> Config:
return Config(str(_ALEMBIC_INI))
def _inspect(db_path: Path):
engine = create_engine(f"sqlite:///{db_path}")
try:
return sa_inspect(engine)
finally:
engine.dispose()
class TestUpgradeHeadFromEmpty:
"""Full chain on a genuinely empty SQLite database (#6)."""
def test_upgrade_head_succeeds_on_empty_database(self, sqlite_database_url):
db_path, _ = sqlite_database_url
command.upgrade(_alembic_config(), "head") # must not raise
insp = _inspect(db_path)
assert set(Base.metadata.tables) <= set(insp.get_table_names())
def test_tables_and_columns_match_base_metadata(self, sqlite_database_url):
"""Every table and column ORM models declare must exist after
`upgrade head` - the exact assertion the analysis asks for."""
db_path, _ = sqlite_database_url
command.upgrade(_alembic_config(), "head")
insp = _inspect(db_path)
db_tables = set(insp.get_table_names())
for table_name, table in Base.metadata.tables.items():
assert table_name in db_tables, f"missing table {table_name}"
db_columns = {c["name"] for c in insp.get_columns(table_name)}
model_columns = {c.name for c in table.columns}
missing = model_columns - db_columns
assert not missing, f"{table_name} missing columns: {missing}"
class TestBaseMigrationNoopsOnExistingSchema:
"""000_base_schema.py does nothing when `recipes` already exists (#6)."""
def test_upgrade_head_noops_000_when_pre_001_schema_already_there(
self, sqlite_database_url
):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
# Build the pre-001 schema and record it as migrated.
command.upgrade(cfg, "000_base")
insp = _inspect(db_path)
assert "recipes" in insp.get_table_names()
assert "image_path" not in {
c["name"] for c in insp.get_columns("recipes")
}
# Erase the tracking: alembic now believes nothing has run, exactly
# like a database that was born from Base.metadata.create_all() and
# never stamped. Running 000's upgrade() again on a database that
# already has `recipes` must be a no-op, not a "table already
# exists" failure.
sync_engine = create_engine(f"sqlite:///{db_path}")
with sync_engine.begin() as conn:
conn.execute(text("DELETE FROM alembic_version"))
sync_engine.dispose()
command.upgrade(cfg, "head") # must not raise (no duplicate table)
insp = _inspect(db_path)
assert set(Base.metadata.tables) <= set(insp.get_table_names())
assert "image_path" in {
c["name"] for c in insp.get_columns("recipes")
}
class TestApiKeyHashMigration:
"""015_api_key_hash: users.api_key -> api_keys table, and back (#41)."""
def test_015_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
command.upgrade(cfg, "head")
insp = _inspect(db_path)
assert "api_keys" in insp.get_table_names()
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
assert {c["name"] for c in insp.get_columns("api_keys")} >= {
"user_id", "key_hash", "created_at", "expires_at", "last_used_at",
}
command.downgrade(cfg, "014_vision_fk_ondelete")
insp = _inspect(db_path)
assert "api_keys" not in insp.get_table_names()
assert "api_key" in {c["name"] for c in insp.get_columns("users")}
command.upgrade(cfg, "head") # and up again
assert "api_keys" in _inspect(db_path).get_table_names()
@@ -0,0 +1,92 @@
"""Unit tests for production_export_service.py.
- CSV format defenses (gitea#23): a dirty csv_delimiter read from the DB must
never make close_run fail with a csv.writer TypeError.
- Formula-injection neutralisation (gitea#22): text cells starting with
=, +, -, @, TAB or CR must be defused before they reach a CSV cell.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.setting import SystemSetting
from src.backend.services.production_export_service import (
_csv_format,
neutralise_csv_cell,
)
class TestNeutraliseCsvCell:
"""gitea#22: formula-injection prefixes must be quoted, plain text untouched."""
def test_equals_prefix_is_quoted(self):
assert neutralise_csv_cell('=HYPERLINK("http://x","y")') == \
'\'=HYPERLINK("http://x","y")'
def test_plus_minus_at_prefixes_are_quoted(self):
assert neutralise_csv_cell("+1;2").startswith("'")
assert neutralise_csv_cell("-cmd|calc").startswith("'")
assert neutralise_csv_cell("@SUM(A1)").startswith("'")
def test_tab_and_cr_prefixes_are_quoted(self):
assert neutralise_csv_cell("\t=1+1").startswith("'")
assert neutralise_csv_cell("\r=1+1").startswith("'")
def test_leading_spaces_before_dangerous_prefix_are_quoted(self):
assert neutralise_csv_cell(" =cmd").startswith("'")
def test_plain_text_is_untouched(self):
assert neutralise_csv_cell("LOT-2026-001") == "LOT-2026-001"
assert neutralise_csv_cell("") == ""
def test_negative_number_as_text_is_still_quoted(self):
"""The function only sees text cells; callers must not pass numeric
cells to it (that responsibility is the caller's, see -1.25 test in
test_measurements.py). Given a string, a leading '-' is neutralised."""
assert neutralise_csv_cell("-1.25").startswith("'")
class TestCsvFormatDefense:
"""`_csv_format` must never hand csv.writer a delimiter it will reject."""
async def test_invalid_delimiter_in_db_falls_back_to_semicolon(
self, db_session: AsyncSession
):
db_session.add(
SystemSetting(
setting_key="csv_delimiter", setting_value=";;", setting_type="string"
)
)
await db_session.flush()
delimiter, _ = await _csv_format(db_session)
assert delimiter == ";"
async def test_empty_delimiter_in_db_falls_back(self, db_session: AsyncSession):
db_session.add(
SystemSetting(
setting_key="csv_delimiter", setting_value="", setting_type="string"
)
)
await db_session.flush()
delimiter, _ = await _csv_format(db_session)
assert delimiter == ";"
async def test_valid_delimiter_passes_through_unchanged(
self, db_session: AsyncSession
):
db_session.add(
SystemSetting(
setting_key="csv_delimiter", setting_value="|", setting_type="string"
)
)
await db_session.flush()
delimiter, _ = await _csv_format(db_session)
assert delimiter == "|"
async def test_no_delimiter_setting_defaults_to_comma(
self, db_session: AsyncSession
):
delimiter, decimal = await _csv_format(db_session)
assert delimiter == ","
assert decimal == "."
+91
View File
@@ -175,6 +175,97 @@ async def test_open_run_rejects_unknown_station(
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Role and station-assignment gate (gitea#24)
# ---------------------------------------------------------------------------
async def test_open_run_requires_measurement_tec_or_supervisor(
client: AsyncClient, maker_user, admin_user, db_session,
):
"""A Maker with no floor role must not be able to start a production."""
station = await _station(db_session, admin_user.id, code="ST-ROLE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ROLE")
resp = await _open(client, maker_user, station, recipe)
assert resp.status_code == 403
async def test_open_run_rejects_recipe_not_assigned_to_station(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
from src.backend.services import station_service
station = await _station(db_session, admin_user.id, code="ST-ASSIGN")
assigned_recipe = await _recipe_with_interval(
db_session, admin_user.id, 15, code="REC-ASSIGNED"
)
other_recipe = await _recipe_with_interval(
db_session, admin_user.id, 15, code="REC-OTHER"
)
await station_service.assign_recipe(db_session, station.id, assigned_recipe.id, admin_user)
await db_session.commit()
resp = await _open(client, measurement_tec_user, station, other_recipe)
assert resp.status_code == 409
async def test_open_run_allows_recipe_assigned_to_station(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
from src.backend.services import station_service
station = await _station(db_session, admin_user.id, code="ST-ASSIGNOK")
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ASSIGNOK")
await station_service.assign_recipe(db_session, station.id, recipe.id, admin_user)
await db_session.commit()
resp = await _open(client, measurement_tec_user, station, recipe)
assert resp.status_code == 201
async def test_open_run_allows_any_recipe_when_station_has_no_assignment(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""An unconfigured station is not restricted - matches the read-side filter
in station_service.list_station_recipes (empty assignments = not filtering)."""
station = await _station(db_session, admin_user.id, code="ST-FREE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-FREE")
resp = await _open(client, measurement_tec_user, station, recipe)
assert resp.status_code == 201
async def test_open_run_rejects_inactive_recipe(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-INACTIVE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-INACTIVE")
recipe.active = False
await db_session.commit()
resp = await _open(client, measurement_tec_user, station, recipe)
assert resp.status_code == 409
async def test_pause_requires_measurement_tec_or_supervisor_actor(
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
):
"""The role gate applies to the acting user - separate from the supervisor
credentials pause already requires in its own body."""
station = await _station(db_session, admin_user.id, code="ST-ROLEPAUSE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-ROLEPAUSE")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
supervisor, password = await _supervisor(db_session, username="capo-rolepause")
resp = await client.post(
f"/api/production-runs/{run_id}/pause",
headers=auth_headers(maker_user),
json={"supervisor_username": supervisor.username, "supervisor_password": password},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Reading the current run - what replaces the in-page state
# ---------------------------------------------------------------------------
+116 -2
View File
@@ -1,5 +1,8 @@
"""Tests for RateLimitMiddleware honoring proxy headers (X-Forwarded-For)."""
import pytest
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
from src.backend.config import settings
class _FakeRequest:
@@ -18,12 +21,33 @@ class _FakeRequest:
self.client.host = host
def test_client_ip_uses_x_forwarded_for_first_hop():
@pytest.fixture(autouse=True)
def default_trusted_proxy_count(monkeypatch):
"""Every test in this file assumes the default (1 trusted proxy)."""
monkeypatch.setattr(settings, "trusted_proxy_count", 1)
def test_client_ip_uses_last_trusted_hop_of_x_forwarded_for():
"""With one trusted proxy, the client IP is the LAST element of XFF -
the one appended by that proxy - not the first, which the caller supplies
and can set to anything.
"""
req = _FakeRequest(
headers={"x-forwarded-for": "203.0.113.5, 10.0.0.2"},
host="10.0.0.1",
)
assert RateLimitMiddleware._client_ip(req) == "203.0.113.5"
assert RateLimitMiddleware._client_ip(req) == "10.0.0.2"
def test_client_ip_ignores_spoofed_first_hop():
"""Rotating the client-supplied (leftmost) part of XFF changes nothing:
the trusted, rightmost hop stays the same, so the bucket key is stable.
"""
req1 = _FakeRequest(headers={"x-forwarded-for": "1.1.1.1, 10.0.0.2"})
req2 = _FakeRequest(headers={"x-forwarded-for": "9.9.9.9, 10.0.0.2"})
assert RateLimitMiddleware._client_ip(req1) == RateLimitMiddleware._client_ip(req2) == "10.0.0.2"
def test_client_ip_strips_whitespace():
@@ -63,3 +87,93 @@ def test_empty_x_forwarded_for_falls_through():
host="10.0.0.1",
)
assert RateLimitMiddleware._client_ip(req) == "203.0.113.42"
def test_x_forwarded_for_shorter_than_trusted_proxy_count_falls_back(monkeypatch):
"""Two trusted proxies configured, but XFF has only one hop: falls back to
request.client.host rather than trusting an under-populated header."""
monkeypatch.setattr(settings, "trusted_proxy_count", 2)
req = _FakeRequest(
headers={"x-forwarded-for": "203.0.113.5"},
host="172.18.0.9",
)
assert RateLimitMiddleware._client_ip(req) == "172.18.0.9"
def test_trusted_proxy_count_two_uses_second_to_last_hop(monkeypatch):
monkeypatch.setattr(settings, "trusted_proxy_count", 2)
req = _FakeRequest(
headers={"x-forwarded-for": "1.1.1.1, 203.0.113.5, 10.0.0.2"},
host="10.0.0.1",
)
assert RateLimitMiddleware._client_ip(req) == "203.0.113.5"
def test_trusted_proxy_count_zero_ignores_all_proxy_headers(monkeypatch):
monkeypatch.setattr(settings, "trusted_proxy_count", 0)
req = _FakeRequest(
headers={
"x-forwarded-for": "203.0.113.5, 10.0.0.2",
"x-real-ip": "203.0.113.99",
},
host="172.18.0.9",
)
assert RateLimitMiddleware._client_ip(req) == "172.18.0.9"
class TestLoginBucketPaths:
"""_is_login_bucket_request: which paths share the login rate-limit bucket."""
def test_login_path_is_login_bucket(self):
assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/auth/login")
def test_login_path_get_is_not_login_bucket(self):
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/login")
def test_measurement_authorise_is_login_bucket(self):
assert RateLimitMiddleware._is_login_bucket_request(
"POST", "/api/measurements/42/authorise"
)
def test_production_pause_resume_close_are_login_bucket(self):
for action in ("pause", "resume", "close"):
assert RateLimitMiddleware._is_login_bucket_request(
"POST", f"/api/production-runs/7/{action}"
)
def test_production_cycle_is_not_login_bucket(self):
"""Only the supervisor-gated actions, not every production-runs path."""
assert not RateLimitMiddleware._is_login_bucket_request(
"POST", "/api/production-runs/7/cycle"
)
def test_setup_paths_are_login_bucket_any_method(self):
assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/setup/init-db")
assert RateLimitMiddleware._is_login_bucket_request("GET", "/api/setup/status")
def test_bare_setup_page_is_not_login_bucket(self):
"""The HTML setup page itself carries no password."""
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/setup")
def test_unrelated_path_is_not_login_bucket(self):
assert not RateLimitMiddleware._is_login_bucket_request("POST", "/api/recipes")
class TestEffectiveLimit:
"""_effective_limit: dividing a configured limit across worker processes."""
def test_single_worker_keeps_configured_limit(self, monkeypatch):
monkeypatch.setattr(settings, "web_concurrency", 1)
assert RateLimitMiddleware._effective_limit(5) == 5
def test_divides_and_rounds_up(self, monkeypatch):
monkeypatch.setattr(settings, "web_concurrency", 4)
assert RateLimitMiddleware._effective_limit(5) == 2 # ceil(5/4)
def test_never_below_one(self, monkeypatch):
monkeypatch.setattr(settings, "web_concurrency", 100)
assert RateLimitMiddleware._effective_limit(5) == 1
def test_non_positive_concurrency_treated_as_one(self, monkeypatch):
monkeypatch.setattr(settings, "web_concurrency", 0)
assert RateLimitMiddleware._effective_limit(5) == 5
@@ -0,0 +1,42 @@
"""Unit test for recipe_service.update_current_version (gitea#36 point 5).
The default task created to hold a drawing when a version has no tasks yet
must be task_type="drawing" (as create_new_version already does at line
~293), not the "note" default — otherwise the operator screen treats a
version with only a drawing as an empty note.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.api.recipe import RecipeUpdate
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeTask
from src.backend.services import recipe_service
async def test_default_task_from_update_current_version_is_drawing_type(
db_session: AsyncSession, admin_user,
):
recipe = Recipe(
code="DRAW-425", name="Drawing only", created_by=admin_user.id,
)
db_session.add(recipe)
await db_session.flush()
version = RecipeVersion(
recipe_id=recipe.id, version_number=1, is_current=True,
created_by=admin_user.id,
)
db_session.add(version)
await db_session.flush()
# No RecipeTask yet: update_current_version must create the default one.
await recipe_service.update_current_version(
db_session, recipe.id, RecipeUpdate(file_path="1/1/drawing.png"),
)
await db_session.commit()
result = await db_session.execute(
RecipeTask.__table__.select().where(RecipeTask.version_id == version.id)
)
task = result.mappings().one()
assert task["task_type"] == "drawing"
+73
View File
@@ -250,6 +250,79 @@ class TestUpdateRecipe:
assert version_numbers == [1, 2]
class TestUpdateRecipeClearNullableFields:
"""gitea#37.3: measurement_interval_minutes/image_path, once set, could
never be cleared again - the request set them and the update endpoint
could not tell "not sent" apart from "sent as null" (both landed on the
same getattr(...) is not None check in recipe_service._header_fields)."""
async def test_explicit_null_clears_measurement_interval(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id)
set_resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"measurement_interval_minutes": 30},
)
assert set_resp.status_code == 200
assert set_resp.json()["measurement_interval_minutes"] == 30
clear_resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"measurement_interval_minutes": None},
)
assert clear_resp.status_code == 200
assert clear_resp.json()["measurement_interval_minutes"] is None
async def test_explicit_null_clears_image_path(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id)
set_resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"image_path": "uploads/1/1/preview.png"},
)
assert set_resp.status_code == 200
assert set_resp.json()["image_path"] == "uploads/1/1/preview.png"
clear_resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"image_path": None},
)
assert clear_resp.status_code == 200
assert clear_resp.json()["image_path"] is None
async def test_field_not_sent_leaves_existing_value_untouched(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
"""Regression guard for the other half of the distinction: a request
that never mentions the field must still leave it as it was."""
recipe = await create_test_recipe(db_session, maker_user.id)
set_resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"measurement_interval_minutes": 45},
)
assert set_resp.status_code == 200
assert set_resp.json()["measurement_interval_minutes"] == 45
unrelated_resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"name": "Renamed Recipe"},
)
assert unrelated_resp.status_code == 200
assert unrelated_resp.json()["measurement_interval_minutes"] == 45
assert unrelated_resp.json()["name"] == "Renamed Recipe"
class TestDeleteRecipe:
"""DELETE /api/recipes/{recipe_id} tests."""
@@ -0,0 +1,33 @@
"""Router-level test for gitea#36 point 7: recipes.py translates
ai_service.AIResponseError to 502, not the config-error 400.
A dedicated file (not test_recipes.py) to avoid colliding with concurrent
work on the copy-on-write recipe logic in that shared test module.
"""
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.user import User
from src.backend.services import ai_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def test_ai_response_error_is_502(
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
monkeypatch,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="AI-PARSE-502")
async def _raise_ai_response_error(_pdf_bytes):
raise ai_service.AIResponseError("Risposta del modello AI non e' JSON valido: boom")
monkeypatch.setattr(ai_service, "parse_technical_sheet", _raise_ai_response_error)
resp = await client.post(
f"/api/recipes/{recipe.id}/parse-technical-sheet",
headers=auth_headers(maker_user),
files={"file": ("sheet.pdf", b"%PDF-1.4 fake", "application/pdf")},
)
assert resp.status_code == 502
+49
View File
@@ -101,6 +101,55 @@ class TestRateLimiting:
retry_val = int(resp.headers["retry-after"])
assert retry_val >= 1
async def test_login_rate_limit_survives_rotated_x_forwarded_for(
self, client: AsyncClient
):
"""A client rotating the spoofable (first) hop of XFF cannot dodge the
login rate limit: with one trusted proxy (the default), the real
client IP is the last hop, which stays constant."""
for i in range(5):
resp = await client.post(
"/api/auth/login",
json={"username": f"rotated{i}", "password": "pass"},
headers={
"X-Forwarded-For": f"{i}.{i}.{i}.{i}, 198.51.100.42",
},
)
assert resp.status_code == 401
resp = await client.post(
"/api/auth/login",
json={"username": "rotated-overflow", "password": "pass"},
headers={"X-Forwarded-For": "9.9.9.9, 198.51.100.42"},
)
assert resp.status_code == 429
async def test_production_run_pause_shares_login_bucket(self, client: AsyncClient):
"""Repeated wrong-password POSTs to a supervisor-gated action are rate
limited under the login bucket, not the (much higher) general one."""
for i in range(5):
resp = await client.post(
f"/api/production-runs/{i + 1}/pause",
json={
"supervisor_username": "capoturno",
"supervisor_password": "wrong",
"note": None,
},
)
# Unauthenticated (no X-API-Key) or run not found either way -
# what matters here is that it is not yet rate limited.
assert resp.status_code != 429
resp = await client.post(
"/api/production-runs/999/pause",
json={
"supervisor_username": "capoturno",
"supervisor_password": "wrong",
"note": None,
},
)
assert resp.status_code == 429
class TestPathTraversal:
"""Path traversal protection in file endpoints."""
+190
View File
@@ -0,0 +1,190 @@
"""Tests for scripts/seed_collaudo.py (issue #17).
The script is not a package module (it is copied into the server container
and run directly), so it is loaded here via importlib from its file path.
"""
import asyncio
import importlib.util
from pathlib import Path
import pytest
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.station import Station
from src.backend.models.orm.user import User
from src.backend.services.auth_service import create_user
from src.backend.tests.conftest import TestSessionFactory, test_engine
_SCRIPT_PATH = Path(__file__).resolve().parents[3] / "scripts" / "seed_collaudo.py"
def _load_script():
spec = importlib.util.spec_from_file_location("seed_collaudo", _SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture
def seed_collaudo(monkeypatch):
module = _load_script()
monkeypatch.setattr(module, "async_session_factory", TestSessionFactory)
return module
@pytest.fixture(autouse=True)
def reset_app_env(monkeypatch):
"""Every test in this file assumes the default (dev)."""
monkeypatch.setattr(settings, "app_env", "dev")
async def _make_admin_and_station(db_session) -> None:
admin = await create_user(
db_session, username="owner", password="OwnerPass1",
display_name="Owner", is_admin=True,
)
db_session.add(Station(
code="ST-DEFAULT", name="Default", created_by=admin.id,
))
await db_session.flush()
class TestReplaceRequiresSi:
"""--replace without --si only reports the impact, never deletes (#17c)."""
async def test_replace_without_si_does_not_delete(self, seed_collaudo, db_session):
await _make_admin_and_station(db_session)
# First run creates COLLAUDO-A/B.
rc = await seed_collaudo.seed("ST-DEFAULT", False, "FirstPass123", True)
assert rc == 0
# Second run with --replace but no --si: must not touch anything.
rc = await seed_collaudo.seed("ST-DEFAULT", True, "SecondPass123", False)
assert rc == 1
recipes = (await db_session.execute(
select(seed_collaudo.Recipe).where(
seed_collaudo.Recipe.code.in_(seed_collaudo.CODES)
)
)).scalars().all()
assert len(recipes) == 2
async def test_replace_with_si_deletes_and_recreates(self, seed_collaudo, db_session):
await _make_admin_and_station(db_session)
rc = await seed_collaudo.seed("ST-DEFAULT", False, "FirstPass123", True)
assert rc == 0
rc = await seed_collaudo.seed("ST-DEFAULT", True, "SecondPass123", True)
assert rc == 0
recipes = (await db_session.execute(
select(seed_collaudo.Recipe).where(
seed_collaudo.Recipe.code.in_(seed_collaudo.CODES)
)
)).scalars().all()
assert len(recipes) == 2
def test_main_with_fake_argv_replace_without_si_does_not_delete(
self, seed_collaudo, monkeypatch
):
"""The exact scenario from the analysis: main() with a fake argv,
--replace but no --si, must leave the recipes untouched.
Plain `def`, not `async def`: main() manages its own event loop via
asyncio.run(), which cannot be called from inside one already running
(as an `async def` test under pytest-asyncio would have).
"""
async def _setup():
async with TestSessionFactory() as session:
await _make_admin_and_station(session)
await session.commit()
asyncio.run(_setup())
assert asyncio.run(
seed_collaudo.seed("ST-DEFAULT", False, "FirstPass123", True)
) == 0
monkeypatch.setattr(
"sys.argv",
["seed_collaudo.py", "--station", "ST-DEFAULT", "--replace"],
)
rc = seed_collaudo.main()
assert rc == 1
async def _count():
async with TestSessionFactory() as session:
recipes = (await session.execute(
select(seed_collaudo.Recipe).where(
seed_collaudo.Recipe.code.in_(seed_collaudo.CODES)
)
)).scalars().all()
return len(recipes)
assert asyncio.run(_count()) == 2
class TestSupervisorPassword:
"""No default --supervisor-password: generated and printed once (#17a)."""
async def test_generates_random_password_when_not_given(
self, seed_collaudo, db_session
):
await _make_admin_and_station(db_session)
rc = await seed_collaudo.seed("ST-DEFAULT", False, None, True)
assert rc == 0
supervisor = (await db_session.execute(
select(User).where(User.username == "capoturno")
)).scalar_one_or_none()
assert supervisor is not None
# A real password was set (create_user always hashes something).
assert supervisor.password_hash
async def test_uses_given_password_when_provided(self, seed_collaudo, db_session):
await _make_admin_and_station(db_session)
rc = await seed_collaudo.seed("ST-DEFAULT", False, "ExplicitPass1", True)
assert rc == 0
from src.backend.services.auth_service import authenticate_user
user = await authenticate_user(db_session, "capoturno", "ExplicitPass1")
assert user is not None
async def test_existing_supervisor_password_untouched(
self, seed_collaudo, db_session
):
"""A second run (no --replace) must not need a password at all."""
await _make_admin_and_station(db_session)
assert await seed_collaudo.seed("ST-DEFAULT", False, "FirstPass123", True) == 0
# Recipes already exist, so seed() stops at the "already present"
# check before ever looking at supervisor_password.
rc = await seed_collaudo.seed("ST-DEFAULT", False, None, True)
assert rc == 1
class TestProductionGuard:
"""Refuses to run against APP_ENV outside dev/test without --conferma-produzione (#17b)."""
def test_main_refuses_in_production_without_confirmation(
self, seed_collaudo, monkeypatch
):
monkeypatch.setattr(settings, "app_env", "production")
monkeypatch.setattr(
"sys.argv", ["seed_collaudo.py", "--station", "ST-DEFAULT"]
)
rc = seed_collaudo.main()
assert rc == 2
def test_main_allows_dev_without_confirmation(self, seed_collaudo, monkeypatch):
monkeypatch.setattr(settings, "app_env", "dev")
# No admin/station set up: seed() itself will fail with rc=2, but that
# proves the production guard did not block it first.
monkeypatch.setattr(
"sys.argv", ["seed_collaudo.py", "--station", "NOPE"]
)
rc = seed_collaudo.main()
assert rc == 2 # "Nessun utente amministratore" / stazione inesistente
+158
View File
@@ -1,4 +1,8 @@
"""Tests for settings router (/api/settings)."""
import io
from pathlib import Path
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
@@ -105,3 +109,157 @@ class TestUpdateSettings:
json={"some_key": "some_value"},
)
assert resp.status_code == 403
class TestUpdateSettingsValidation:
"""gitea#23: an invalid csv_delimiter must be rejected, not accepted and
left to blow up close_run later with a csv.writer TypeError."""
async def test_csv_delimiter_two_chars_rejected(
self, client: AsyncClient, admin_user: User
):
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"csv_delimiter": ";;"},
)
assert resp.status_code == 422
async def test_csv_delimiter_empty_rejected(
self, client: AsyncClient, admin_user: User
):
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"csv_delimiter": ""},
)
assert resp.status_code == 422
async def test_csv_delimiter_tab_accepted(
self, client: AsyncClient, admin_user: User
):
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"csv_delimiter": "\t"},
)
assert resp.status_code == 200
async def test_csv_decimal_separator_invalid_rejected(
self, client: AsyncClient, admin_user: User
):
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"csv_decimal_separator": "x"},
)
assert resp.status_code == 422
async def test_auto_logout_minutes_out_of_range_rejected(
self, client: AsyncClient, admin_user: User
):
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"auto_logout_minutes": "0"},
)
assert resp.status_code == 422
async def test_auto_logout_minutes_empty_disables(
self, client: AsyncClient, admin_user: User
):
"""Empty string is what the settings page sends to disable auto-logout."""
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"auto_logout_minutes": ""},
)
assert resp.status_code == 200
async def test_unknown_key_not_validated(
self, client: AsyncClient, admin_user: User
):
"""Keys with no known validator pass through (generic settings store)."""
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"some_future_key": "anything"},
)
assert resp.status_code == 200
class TestUploadLogo:
"""POST /api/settings/logo tests (gitea#36 point 2)."""
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
async def test_valid_png_accepted(
self, client: AsyncClient, admin_user: User, tmp_path: Path
):
with patch("src.backend.api.routers.settings.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/settings/logo",
headers=auth_headers(admin_user),
files={"file": ("logo.png", io.BytesIO(self._PNG_BYTES), "image/png")},
)
assert resp.status_code == 200
assert resp.json()["logo_path"].endswith(".png")
async def test_svg_refused_even_with_svg_content_type(
self, client: AsyncClient, admin_user: User, tmp_path: Path
):
"""SVG is refused outright, not just when it lies about its type."""
svg_bytes = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
with patch("src.backend.api.routers.settings.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/settings/logo",
headers=auth_headers(admin_user),
files={"file": ("logo.svg", io.BytesIO(svg_bytes), "image/svg+xml")},
)
assert resp.status_code == 400
assert not any(tmp_path.rglob("*.svg"))
async def test_pdf_refused_for_logo(
self, client: AsyncClient, admin_user: User, tmp_path: Path
):
"""The logo slot only accepts images: files.py's PDF signature must
not slip through settings.py's narrower ALLOWED_IMAGE_TYPES check."""
pdf_bytes = b"%PDF-1.4\n" + b"\x00" * 20
with patch("src.backend.api.routers.settings.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/settings/logo",
headers=auth_headers(admin_user),
files={"file": ("logo.pdf", io.BytesIO(pdf_bytes), "application/pdf")},
)
assert resp.status_code == 400
async def test_html_disguised_as_png_refused(
self, client: AsyncClient, admin_user: User, tmp_path: Path
):
html_bytes = b"<html><body><script>alert(1)</script></body></html>"
with patch("src.backend.api.routers.settings.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/settings/logo",
headers=auth_headers(admin_user),
files={"file": ("logo.png", io.BytesIO(html_bytes), "image/png")},
)
assert resp.status_code == 400
+169 -3
View File
@@ -23,6 +23,11 @@ def enable_setup(monkeypatch):
import src.backend.api.routers.setup as setup_mod
monkeypatch.setattr(setup_mod, "engine", test_engine)
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
# _stamp_head runs `alembic stamp head`, which builds its own connection
# from settings.database_url via migrations/env.py - the real MySQL URL,
# not the patched test engine above. No-op it here so init-db/reset-db
# tests never try to reach a MySQL server that does not exist in CI/dev.
monkeypatch.setattr(setup_mod, "_stamp_head", lambda: None)
async def _seed(client: AsyncClient) -> dict:
@@ -157,9 +162,12 @@ async def test_create_user(client: AsyncClient):
@pytest.mark.asyncio
async def test_create_user_duplicate_username(client: AsyncClient):
async def test_create_user_duplicate_username(client: AsyncClient, monkeypatch):
"""Creating a user with a duplicate username should return 409."""
await _seed(client)
# _seed already created an admin; bypass the "setup already completed"
# guard (#9) so this test can still exercise manage-user's own checks.
monkeypatch.setattr(settings, "setup_allow_after_init", True)
resp = await client.post("/api/setup/manage-user", json={
"password": SETUP_PWD,
@@ -188,9 +196,10 @@ async def test_create_user_without_password(client: AsyncClient):
@pytest.mark.asyncio
async def test_update_user(client: AsyncClient):
async def test_update_user(client: AsyncClient, monkeypatch):
"""Update an existing user via manage-user endpoint."""
await _seed(client)
monkeypatch.setattr(settings, "setup_allow_after_init", True)
# Get users to find the ID
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
@@ -232,9 +241,10 @@ async def test_create_user_invalid_role(client: AsyncClient):
@pytest.mark.asyncio
async def test_change_password(client: AsyncClient):
async def test_change_password(client: AsyncClient, monkeypatch):
"""Changing password should succeed and invalidate api_key."""
await _seed(client)
monkeypatch.setattr(settings, "setup_allow_after_init", True)
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
users = resp.json()["users"]
@@ -292,3 +302,159 @@ async def test_toggle_user_active(client: AsyncClient):
})
assert resp.status_code == 200
assert resp.json()["active"] is True
# ---------------------------------------------------------------------------
# Feature 3: _check_password is timing-safe
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_wrong_password_still_rejected_with_compare_digest(client: AsyncClient):
"""hmac.compare_digest must not change the pass/fail outcome."""
resp = await client.post("/api/setup/list-users", json={"password": "definitely-wrong"})
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_correct_password_still_accepted_with_compare_digest(client: AsyncClient):
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Feature 4: setup already completed - privileged endpoints blocked (#9)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_privileged_endpoints_blocked_once_admin_exists(client: AsyncClient, monkeypatch):
"""reset-db, seed, manage-user, change-user-password, init-db all 403
once an admin exists, unless setup_allow_after_init is set."""
# This test makes more than rate_limit_login (5/min) requests to
# /api/setup/* on purpose, to exercise every guarded endpoint - raise the
# limit so it is the #9 guard being tested, not the #8/#9(d) rate limit.
monkeypatch.setattr(settings, "rate_limit_login", 100)
data = await _seed(client) # creates the "admin" user (is_admin=True)
admin_id = None
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
for u in resp.json()["users"]:
if u["username"] == "admin":
admin_id = u["id"]
assert admin_id is not None
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 403
assert "already completed" in resp.json()["detail"]
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
assert resp.status_code == 403
resp = await client.post("/api/setup/manage-user", json={
"password": SETUP_PWD,
"user_id": None,
"username": "blocked",
"user_password": "pass123",
"display_name": "Blocked",
"roles": [],
})
assert resp.status_code == 403
resp = await client.post("/api/setup/change-user-password", json={
"password": SETUP_PWD,
"user_id": admin_id,
"new_password": "whatever123",
})
assert resp.status_code == 403
resp = await client.post("/api/setup/reset-db", json={
"password": SETUP_PWD,
"confirm": True,
})
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_privileged_endpoints_allowed_before_any_admin(client: AsyncClient):
"""On a fresh setup (no admin yet), init-db and seed still work."""
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_setup_allow_after_init_overrides_guard(client: AsyncClient, monkeypatch):
"""SETUP_ALLOW_AFTER_INIT=true restores the old (pre-#9) behaviour."""
await _seed(client)
monkeypatch.setattr(settings, "setup_allow_after_init", True)
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_toggle_user_active_not_guarded(client: AsyncClient):
"""toggle-user-active is not in the guarded list, admin or not."""
await _seed(client)
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
maker = next(u for u in resp.json()["users"] if u["username"] == "maker1")
resp = await client.post("/api/setup/toggle-user-active", json={
"password": SETUP_PWD,
"user_id": maker["id"],
})
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_create_admin_not_guarded(client: AsyncClient):
"""create-admin is not in the guarded list either (see analysis of #9)."""
await _seed(client)
resp = await client.post("/api/setup/create-admin", json={
"password": SETUP_PWD,
"admin_username": "second_admin",
"admin_password": "pass12345",
"admin_email": "second@test.com",
"admin_display_name": "Second Admin",
})
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Feature 5: GET /api/setup/status without/with password (#9)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_status_without_password_is_minimal(client: AsyncClient):
resp = await client.get("/api/setup/status")
assert resp.status_code == 200
data = resp.json()
assert data == {"initialised": False}
@pytest.mark.asyncio
async def test_status_with_wrong_password_is_minimal(client: AsyncClient):
resp = await client.get("/api/setup/status", params={"password": "nope"})
assert resp.status_code == 200
assert resp.json() == {"initialised": False}
@pytest.mark.asyncio
async def test_status_with_valid_password_lists_tables(client: AsyncClient):
await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
resp = await client.get("/api/setup/status", params={"password": SETUP_PWD})
assert resp.status_code == 200
data = resp.json()
assert "tables" in data
assert "table_count" in data
assert data["tables_exist"] is True
@pytest.mark.asyncio
async def test_status_initialised_true_after_seed(client: AsyncClient):
await _seed(client)
resp = await client.get("/api/setup/status")
assert resp.status_code == 200
assert resp.json() == {"initialised": True}
+123
View File
@@ -0,0 +1,123 @@
"""Unit tests for spc_service.py: Cp/Cpk (within, moving-range) vs Pp/Ppk (overall).
Expected values are computed in each test from the formulas themselves
(not copied from the implementation's output), so a regression in the
formula would also break the test's own expectation derivation... except
we compute them independently here with plain stdlib code, mirroring the
spec in gitea:Adriano/TieMeasureFlow#19.
"""
import statistics as stats
from datetime import datetime, timedelta
from src.backend.services.spc_service import compute_capability, compute_control_chart
def _expected_sigma_within(values: list[float]) -> float | None:
"""Independent re-implementation of MRbar/1.128 for test expectations."""
if len(values) < 2:
return None
mr = [abs(values[i] - values[i - 1]) for i in range(1, len(values))]
mr_bar = stats.mean(mr)
if mr_bar == 0:
return None
return mr_bar / 1.128
# 13 values around 10.00, then 12 values around 10.08: a level shift that
# a global (overall) std dev smears, but that stays invisible to the
# consecutive-point moving range within each stable block.
_SHIFTED_SERIES = (
[10.00, 10.01, 9.99, 10.02, 9.98, 10.01, 9.99, 10.00, 10.02, 9.98, 10.01, 9.99, 10.00]
+ [10.08, 10.09, 10.07, 10.08, 10.09, 10.07, 10.08, 10.09, 10.07, 10.08, 10.09, 10.07]
)
class TestComputeCapability:
def test_cp_uses_sigma_within_pp_uses_overall_sample_stdev(self):
utl, ltl = 10.2, 9.8
result = compute_capability(_SHIFTED_SERIES, utl, ltl, nominal=10.0)
sample_std = stats.stdev(_SHIFTED_SERIES)
sigma_within = _expected_sigma_within(_SHIFTED_SERIES)
assert sigma_within is not None
expected_cp = round((utl - ltl) / (6 * sigma_within), 4)
expected_pp = round((utl - ltl) / (6 * sample_std), 4)
assert result.cp == expected_cp
assert result.pp == expected_pp
# Cp (within, tight) must differ from Pp (overall, widened by the
# level shift) — this is exactly the drift #19 says must surface.
assert result.cp != result.pp
assert result.cp > result.pp
assert result.std_dev == round(sample_std, 6)
assert result.std_dev_within == round(sigma_within, 6)
def test_constant_series_returns_none_indices(self):
values = [10.0] * 10
result = compute_capability(values, 10.2, 9.8, 10.0)
assert result.cp is None
assert result.cpk is None
assert result.pp is None
assert result.ppk is None
assert result.std_dev_within is None
def test_n_less_than_2_returns_none(self):
result = compute_capability([10.0], 10.2, 9.8, 10.0)
assert result.cp is None
assert result.cpk is None
assert result.pp is None
assert result.ppk is None
assert result.n == 1
def test_one_sided_tolerance_only_cpk(self):
values = [10.0, 10.01, 9.99, 10.02, 9.98, 10.01, 9.99, 10.0]
result = compute_capability(values, utl=10.2, ltl=None, nominal=10.0)
assert result.cp is None # both UTL and LTL required
assert result.pp is None
assert result.cpk is not None
assert result.ppk is not None
class TestComputeControlChart:
def test_ucl_lcl_use_sigma_within_not_overall_stdev(self):
base = datetime(2026, 1, 1)
timestamps = [base + timedelta(minutes=i) for i in range(len(_SHIFTED_SERIES))]
result = compute_control_chart(
_SHIFTED_SERIES, timestamps, 10.2, None, None, 9.8, 10.0
)
mean = stats.mean(_SHIFTED_SERIES)
sigma_within = _expected_sigma_within(_SHIFTED_SERIES)
expected_ucl = round(mean + 3 * sigma_within, 6)
expected_lcl = round(mean - 3 * sigma_within, 6)
assert result.ucl == expected_ucl
assert result.lcl == expected_lcl
# Points beyond the within-sigma limits must be flagged: the level
# shift is detectable this way but was invisible with the overall
# sample std dev (limits too wide to ever trip).
assert result.out_of_control != []
overall_std = stats.stdev(_SHIFTED_SERIES)
overall_ucl = mean + 3 * overall_std
overall_lcl = mean - 3 * overall_std
overall_ooc = [
i for i, v in enumerate(_SHIFTED_SERIES) if v > overall_ucl or v < overall_lcl
]
assert overall_ooc == []
def test_constant_series_zero_limits(self):
values = [10.0] * 6
timestamps = [datetime(2026, 1, 1) + timedelta(minutes=i) for i in range(6)]
result = compute_control_chart(values, timestamps, 10.2, None, None, 9.8, 10.0)
assert result.ucl == 10.0
assert result.lcl == 10.0
assert result.out_of_control == []
def test_single_value_zero_limits(self):
timestamps = [datetime(2026, 1, 1)]
result = compute_control_chart([10.0], timestamps, 10.2, None, None, 9.8, 10.0)
assert result.ucl == 10.0
assert result.lcl == 10.0
+9
View File
@@ -34,6 +34,9 @@ def enable_setup(monkeypatch):
import src.backend.api.routers.setup as setup_mod
monkeypatch.setattr(setup_mod, "engine", test_engine)
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
# See test_setup.py: _stamp_head would otherwise try to reach a real
# MySQL server via migrations/env.py.
monkeypatch.setattr(setup_mod, "_stamp_head", lambda: None)
async def _seed(client: AsyncClient) -> dict:
@@ -83,11 +86,17 @@ async def test_setup_seed_creates_default_station_and_assigns_recipes(
async def test_setup_seed_station_idempotent(
client: AsyncClient,
db_session: AsyncSession,
monkeypatch,
):
"""Running seed twice must not duplicate ST-DEFAULT or its assignments."""
# First run — creates everything.
await _seed(client)
# Second run: the first seed already created an admin, so this exercises
# setup's own "setup already completed" guard (#9) unless bypassed - the
# idempotency this test is about is seed's, not the guard's.
monkeypatch.setattr(settings, "setup_allow_after_init", True)
# Second run — recipe DEMO-001 already exists → seed returns early.
# ST-DEFAULT must still exist (not re-created, not duplicated).
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
@@ -0,0 +1,120 @@
"""#43: reference files removed with their task, animated images refused,
GET /api/measurements filtered by production run."""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_vision_upload_limits import a_png, recipe_parts
@pytest.fixture(autouse=True)
def redirect_uploads(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
async def _upload_reference(client, user, task_id):
resp = await client.post(
"/api/vision/reference-images", headers=auth_headers(user),
files={"image": ("p.png", a_png(), "image/png")},
data={"task_id": str(task_id)},
)
assert resp.status_code in (200, 201), resp.text
@pytest.mark.asyncio
async def test_delete_task_removes_the_reference_file(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
await db_session.commit()
await _upload_reference(client, maker_user, task.id)
ref = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
path = settings.upload_path / ref.path
assert path.exists()
resp = await client.delete(f"/api/tasks/{task.id}", headers=auth_headers(maker_user))
assert resp.status_code == 204
assert not path.exists()
@pytest.mark.asyncio
async def test_delete_task_keeps_a_file_another_task_still_references(
client, db_session, maker_user,
):
r1 = await create_test_recipe(db_session, maker_user.id)
r2 = await create_test_recipe(db_session, maker_user.id, code="REC-002")
_, t1, _ = await recipe_parts(db_session, r1.id)
_, t2, _ = await recipe_parts(db_session, r2.id)
await db_session.commit()
await _upload_reference(client, maker_user, t1.id)
ref = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
db_session.add(VisionReferenceImage(task_id=t2.id, path=ref.path))
await db_session.commit()
path = settings.upload_path / ref.path
resp = await client.delete(f"/api/tasks/{t1.id}", headers=auth_headers(maker_user))
assert resp.status_code == 204
assert path.exists()
def _animated(fmt: str, frames: int) -> bytes:
imgs = [Image.new("L", (16, 16), color=i * 40) for i in range(frames)]
buf = io.BytesIO()
imgs[0].save(buf, format=fmt, save_all=True, append_images=imgs[1:], duration=50)
return buf.getvalue()
@pytest.mark.asyncio
@pytest.mark.parametrize("fmt,mime,frames,expected", [
("GIF", "image/gif", 2, 422),
("WEBP", "image/webp", 2, 422),
("GIF", "image/gif", 1, 201),
])
async def test_animated_images_are_refused(
client, db_session, maker_user, fmt, mime, frames, expected,
):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
await db_session.commit()
resp = await client.post(
"/api/vision/reference-images", headers=auth_headers(maker_user),
files={"image": ("a", _animated(fmt, frames), mime)},
data={"task_id": str(task.id)},
)
if expected == 422:
assert resp.status_code == 422
assert "animated" in resp.json()["detail"]
else:
assert resp.status_code in (200, 201), resp.text
@pytest.mark.asyncio
async def test_measurements_filter_by_production_run(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
version, _, subtask = await recipe_parts(db_session, recipe.id)
for run in (7, 7, 8, None):
db_session.add(Measurement(
subtask_id=subtask.id, version_id=version.id, measured_by=maker_user.id,
value=1.0, pass_fail="pass", deviation=0.0, input_method="manual",
production_run_id=run,
))
await db_session.commit()
resp = await client.get(
"/api/measurements/", headers=auth_headers(maker_user),
params={"production_run_id": 7},
)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert {m["production_run_id"] for m in body["items"]} == {7}
+313 -1
View File
@@ -7,6 +7,14 @@ from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def _measure(client, user, subtask_id, version_id, value=10.0):
return await client.post(
"/api/measurements/",
headers=auth_headers(user),
json={"subtask_id": subtask_id, "version_id": version_id, "value": value},
)
class TestListTasks:
"""GET /api/recipes/{recipe_id}/tasks tests."""
@@ -65,6 +73,45 @@ class TestCreateTask:
assert len(data["subtasks"]) == 1
assert data["subtasks"][0]["description"] == "Surface roughness"
async def test_create_task_on_version_with_measurements_has_recipe_id(
self,
client: AsyncClient,
maker_user: User,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Copy-on-write branch (gitea#4): TaskResponse.recipe_id must not crash."""
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-CT")
task = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]
subtask_id = task["subtasks"][0]["id"]
assert (await _measure(
client, measurement_tec_user, subtask_id, task["version_id"]
)).status_code == 200
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={
"title": "New Task After Measurement",
"subtasks": [
{
"marker_number": 1,
"description": "Surface roughness",
"nominal": 5.0,
"utl": 5.5,
"ltl": 4.5,
"unit": "um",
}
],
},
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["recipe_id"] == recipe.id
assert data["version_id"] != task["version_id"]
class TestUpdateTask:
"""PUT /api/tasks/{task_id} tests."""
@@ -171,7 +218,9 @@ class TestUpdateSubtask:
resp = await client.put(
f"/api/subtasks/{subtask_id}",
headers=auth_headers(maker_user),
json={"description": "Updated description", "nominal": 12.0},
# nominal stays inside the fixture's own lwl(9.7)/uwl(10.3): #26
# rejects a nominal like 12.0 that would land past utl(10.5).
json={"description": "Updated description", "nominal": 10.2},
)
assert resp.status_code == 200
assert resp.json()["description"] == "Updated description"
@@ -221,3 +270,266 @@ class TestReorderTasks:
assert resp.status_code == 200
reordered = resp.json()
assert [t["id"] for t in reordered] == reversed_ids
class TestCopyOnWriteOnEdit:
"""A version with measurements is never edited in place (gitea#4)."""
async def test_update_task_with_measurements_creates_new_version(
self,
client: AsyncClient,
maker_user: User,
measurement_tec_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-UT")
task = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]
task_id, old_version_id = task["id"], task["version_id"]
subtask_id = task["subtasks"][0]["id"]
assert (await _measure(
client, measurement_tec_user, subtask_id, old_version_id
)).status_code == 200
resp = await client.put(
f"/api/tasks/{task_id}",
headers=auth_headers(maker_user),
json={"title": "Titolo nuovo"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["version_id"] != old_version_id
assert resp.json()["title"] == "Titolo nuovo"
# The measured version keeps its original title and its versions count grew.
old = (await client.get(
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
)).json()
assert old["tasks"][0]["title"] == "Test Task"
versions = (await client.get(
f"/api/recipes/{recipe.id}/versions", headers=auth_headers(maker_user),
)).json()
assert len(versions) == 2
# The measurement is still findable under the old version.
listed = (await client.get(
"/api/measurements/", headers=auth_headers(measurement_tec_user),
params={"version_id": old_version_id},
)).json()
assert listed["total"] == 1
async def test_delete_task_with_measurements_creates_new_version(
self,
client: AsyncClient,
maker_user: User,
measurement_tec_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-DT")
task = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]
task_id, old_version_id = task["id"], task["version_id"]
subtask_id = task["subtasks"][0]["id"]
assert (await _measure(
client, measurement_tec_user, subtask_id, old_version_id
)).status_code == 200
resp = await client.delete(
f"/api/tasks/{task_id}", headers=auth_headers(maker_user),
)
assert resp.status_code == 204
# The measured version still has its task untouched.
old = (await client.get(
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
)).json()
assert len(old["tasks"]) == 1
# The new current version has the task removed. expunge_all forces a
# fresh read - the shared test session otherwise still has the old
# RecipeVersion.tasks collection cached from create_test_recipe.
db_session.expunge_all()
current = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()
assert current == []
async def test_update_subtask_with_measurements_creates_new_version(
self,
client: AsyncClient,
maker_user: User,
measurement_tec_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-US")
task = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]
old_version_id = task["version_id"]
subtask = task["subtasks"][0]
subtask_id = subtask["id"]
assert (await _measure(
client, measurement_tec_user, subtask_id, old_version_id
)).status_code == 200
resp = await client.put(
f"/api/subtasks/{subtask_id}",
headers=auth_headers(maker_user),
json={"description": "Quota rivista"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["description"] == "Quota rivista"
assert resp.json()["id"] != subtask_id, "la modifica va sulla copia, non sull'originale"
# The measured version's own quote is untouched.
old = (await client.get(
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
)).json()
assert old["tasks"][0]["subtasks"][0]["description"] == subtask["description"]
async def test_delete_subtask_with_measurements_creates_new_version(
self,
client: AsyncClient,
maker_user: User,
measurement_tec_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-DS")
task = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]
old_version_id = task["version_id"]
subtask_id = task["subtasks"][0]["id"]
assert (await _measure(
client, measurement_tec_user, subtask_id, old_version_id
)).status_code == 200
resp = await client.delete(
f"/api/subtasks/{subtask_id}", headers=auth_headers(maker_user),
)
assert resp.status_code == 204
# The measured version keeps its quote.
old = (await client.get(
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
)).json()
assert len(old["tasks"][0]["subtasks"]) == 1
# The new current version has no quotes left on the task. expunge_all
# forces a fresh read past the shared test session's cached collection.
db_session.expunge_all()
current = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()
assert current[0]["subtasks"] == []
async def test_create_subtask_with_measurements_creates_new_version(
self,
client: AsyncClient,
maker_user: User,
measurement_tec_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-CS")
task = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]
task_id, old_version_id = task["id"], task["version_id"]
subtask_id = task["subtasks"][0]["id"]
assert (await _measure(
client, measurement_tec_user, subtask_id, old_version_id
)).status_code == 200
resp = await client.post(
f"/api/tasks/{task_id}/subtasks",
headers=auth_headers(maker_user),
json={"marker_number": 9, "description": "Nuova quota", "utl": 5.0},
)
assert resp.status_code == 201, resp.text
# The measured version's task is unchanged (still one quote).
old = (await client.get(
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
)).json()
assert len(old["tasks"][0]["subtasks"]) == 1
# The new current version's task has both quotes. expunge_all forces a
# fresh read past the shared test session's cached collection.
db_session.expunge_all()
current = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()
assert len(current[0]["subtasks"]) == 2
class TestToleranceOrder:
"""ltl <= lwl <= nominal <= uwl <= utl, or 422 (gitea#26)."""
async def test_create_subtask_utl_below_ltl_is_422(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-A")
task_id = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]["id"]
resp = await client.post(
f"/api/tasks/{task_id}/subtasks",
headers=auth_headers(maker_user),
json={"marker_number": 5, "description": "Quota invertita", "ltl": 8, "utl": 6},
)
assert resp.status_code == 422
async def test_create_subtask_uwl_above_utl_is_422(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-B")
task_id = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]["id"]
resp = await client.post(
f"/api/tasks/{task_id}/subtasks",
headers=auth_headers(maker_user),
json={"marker_number": 5, "description": "Warning oltre tolleranza", "uwl": 11, "utl": 10},
)
assert resp.status_code == 422
async def test_create_subtask_one_sided_tolerance_is_ok(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-C")
task_id = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]["id"]
resp = await client.post(
f"/api/tasks/{task_id}/subtasks",
headers=auth_headers(maker_user),
json={"marker_number": 5, "description": "Solo utl", "utl": 10.0},
)
assert resp.status_code == 201, resp.text
async def test_update_subtask_that_would_invert_the_order_is_422(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
"""Partial update: the new value is checked against what stays (#26)."""
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-D")
subtask_id = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]["subtasks"][0]["id"]
# Fixture quote: ltl=9.5, lwl=9.7, nominal=10.0, uwl=10.3, utl=10.5.
# Raising ltl past the existing utl inverts the sequence.
resp = await client.put(
f"/api/subtasks/{subtask_id}",
headers=auth_headers(maker_user),
json={"ltl": 11.0},
)
assert resp.status_code == 422
+113
View File
@@ -0,0 +1,113 @@
"""UTC in the DB, explicit +00:00 on the wire, local time only on presentation (#42)."""
from datetime import datetime, timezone
from unittest.mock import patch
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.user import User
from src.backend.services import report_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.utils.tempo import as_utc_naive, to_local, utcnow
def test_utcnow_naive_and_close_to_utc():
now = utcnow()
assert now.tzinfo is None
delta = datetime.now(timezone.utc).replace(tzinfo=None) - now
assert abs(delta.total_seconds()) < 5
def test_as_utc_naive():
parsed = datetime.fromisoformat("2026-10-06T10:00:00+02:00")
assert as_utc_naive(parsed) == datetime(2026, 10, 6, 8, 0)
assert as_utc_naive(parsed).tzinfo is None
naive = datetime(2026, 10, 6, 8, 0)
assert as_utc_naive(naive) is naive
assert as_utc_naive(None) is None
def test_to_local_is_rome():
assert to_local(datetime(2026, 10, 6, 8, 0)).strftime("%H:%M %z") == "10:00 +0200"
assert to_local(datetime(2026, 1, 6, 8, 0)).strftime("%H:%M %z") == "09:00 +0100"
async def _make_measurement(client, user, recipe_id) -> None:
tasks = (await client.get(f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user))).json()
await client.post(
"/api/measurements/",
headers=auth_headers(user),
json={
"subtask_id": tasks[0]["subtasks"][0]["id"],
"version_id": tasks[0]["version_id"],
"value": 10.0,
},
)
async def test_api_datetimes_carry_utc_offset_and_filter_equivalence(
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
await _make_measurement(client, measurement_tec_user, recipe.id)
resp = await client.get("/api/measurements/", headers=auth_headers(metrologist_user))
item = resp.json()["items"][0]
assert item["measured_at"].endswith("+00:00")
# Same instant written with +02:00 and as UTC: identical results.
future_utc = "2099-01-01T08:00:00+00:00"
future_rome = "2099-01-01T10:00:00+02:00"
past_utc = "2000-01-01T08:00:00+00:00"
past_rome = "2000-01-01T10:00:00+02:00"
for frm, to in ((past_utc, future_utc), (past_rome, future_rome)):
r = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
params={"date_from": frm, "date_to": to},
)
assert r.status_code == 200
assert r.json()["total"] == 1
# Boundary: measurement is "now" in UTC; a date_from 1h ahead in UTC,
# written as +02:00 (now+3h local wall clock), must exclude it.
ahead = (utcnow().replace(microsecond=0)).replace(tzinfo=timezone.utc)
from datetime import timedelta
ahead_utc = (ahead + timedelta(hours=1)).isoformat()
ahead_rome = (ahead + timedelta(hours=1)).astimezone(
timezone(timedelta(hours=2))
).isoformat()
totals = []
for frm in (ahead_utc, ahead_rome):
r = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
params={"date_from": frm},
)
totals.append(r.json()["total"])
assert totals == [0, 0]
async def test_report_generated_at_is_local_time(
db_session: AsyncSession, metrologist_user: User
):
recipe = await create_test_recipe(db_session, metrologist_user.id)
captured: dict[str, str] = {}
def fake_render(html: str) -> bytes:
captured["html"] = html
return b"%PDF"
with patch.object(report_service, "utcnow", return_value=datetime(2026, 10, 6, 8, 0)), \
patch.object(report_service, "_render_pdf", side_effect=fake_render):
await report_service.generate_measurement_report(db_session, recipe.id)
assert "Generated 2026-10-06 10:00" in captured["html"]
def test_report_filter_day_is_local():
"""UTC naive 22:00 of Oct 5 is already Oct 6 in Europe/Rome (#42)."""
assert report_service._giorno_locale(datetime(2026, 10, 5, 22, 0, 0)) == "2026-10-06"
@@ -0,0 +1,74 @@
"""Tests for PUT /api/users/{id}/password (admin only) - issue #25(c).
Kept in its own file rather than test_users.py: that file covers the rest of
the users router, worked on in a different lot in the same session.
"""
from httpx import AsyncClient
from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers
class TestChangeUserPassword:
async def test_admin_changes_password_invalidates_api_key(
self, client: AsyncClient, admin_user: User, maker_user: User
):
"""Changing a user's password invalidates their existing api_key,
the same as setup.py's change-user-password (setup.py:634).
"""
old_key = maker_user.plain_key
old_username = maker_user.username
assert old_key is not None
resp = await client.put(
f"/api/users/{maker_user.id}/password",
headers=auth_headers(admin_user),
json={"password": "NewSecurePass1"},
)
assert resp.status_code == 200
assert "changed" in resp.json()["message"].lower()
# The old key no longer authenticates. Read it before the request
# above: the client and app share one session (see conftest), so
# `maker_user.plain_key` in this process reflects the mutation too.
resp2 = await client.get("/api/auth/me", headers={"X-API-Key": old_key})
assert resp2.status_code == 401
# The new password works and issues a fresh key.
resp3 = await client.post(
"/api/auth/login",
json={"username": old_username, "password": "NewSecurePass1"},
)
assert resp3.status_code == 200
assert resp3.json()["api_key"] != old_key
async def test_non_admin_forbidden(
self, client: AsyncClient, maker_user: User
):
resp = await client.put(
f"/api/users/{maker_user.id}/password",
headers=auth_headers(maker_user),
json={"password": "WhateverPass1"},
)
assert resp.status_code == 403
async def test_user_not_found(
self, client: AsyncClient, admin_user: User
):
resp = await client.put(
"/api/users/999999/password",
headers=auth_headers(admin_user),
json={"password": "WhateverPass1"},
)
assert resp.status_code == 404
async def test_password_too_short_rejected(
self, client: AsyncClient, admin_user: User, maker_user: User
):
resp = await client.put(
f"/api/users/{maker_user.id}/password",
headers=auth_headers(admin_user),
json={"password": "short"},
)
assert resp.status_code == 422
+55
View File
@@ -107,6 +107,61 @@ class TestUpdateUser:
assert resp.json()["display_name"] == "Updated Maker"
class TestUpdateUserValidation:
"""gitea#36 point 4: roles/is_admin cannot be null, admin self/last-admin guard."""
async def test_roles_null_is_422(
self, client: AsyncClient, admin_user: User, maker_user: User,
):
"""roles is Optional in the schema but NOT NULL in the DB: an explicit
null used to reach setattr() and blow up as an unhandled 500."""
resp = await client.put(
f"/api/users/{maker_user.id}",
headers=auth_headers(admin_user),
json={"roles": None},
)
assert resp.status_code == 422
async def test_is_admin_null_is_422(
self, client: AsyncClient, admin_user: User, maker_user: User,
):
resp = await client.put(
f"/api/users/{maker_user.id}",
headers=auth_headers(admin_user),
json={"is_admin": None},
)
assert resp.status_code == 422
async def test_admin_cannot_remove_own_admin_flag(
self, client: AsyncClient, admin_user: User,
):
"""admin_user is the only admin: this is also the only reachable
"last admin" case, since only an admin can call this endpoint at
all — removing someone else's flag always leaves the caller."""
resp = await client.put(
f"/api/users/{admin_user.id}",
headers=auth_headers(admin_user),
json={"is_admin": False},
)
assert resp.status_code == 409
async def test_admin_can_demote_another_admin_when_not_last(
self, client: AsyncClient, admin_user: User, db_session: AsyncSession,
):
"""Demoting someone else is fine as long as an admin remains (the
caller): the guard must not be broader than the two stated cases."""
second_admin = await _create_user(
db_session, username="second_admin", roles=[], is_admin=True,
)
resp = await client.put(
f"/api/users/{second_admin.id}",
headers=auth_headers(admin_user),
json={"is_admin": False},
)
assert resp.status_code == 200
assert resp.json()["is_admin"] is False
class TestDeleteUser:
"""DELETE /api/users/{user_id} tests."""
+44
View File
@@ -159,6 +159,50 @@ async def test_a_missing_output_names_itself(
assert (await db_session.execute(select(Measurement))).scalars().all() == []
@pytest.mark.asyncio
async def test_a_null_output_counts_as_not_produced(
client, db_session, measurement_tec_user, worker_says,
):
"""A worker answering with the key present but null (not omitted) must not
reach float(None) as a bare TypeError - it is "not produced", same as a
missing key (#16)."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.diameter": None, "t1.radius": GOOD})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
assert "t1.diameter" in response.json()["detail"]
assert (await db_session.execute(select(Measurement))).scalars().all() == []
@pytest.mark.asyncio
async def test_a_non_finite_output_counts_as_not_produced(
client, db_session, measurement_tec_user, worker_says,
):
"""NaN/inf must not become a saved measurement (#16)."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
assert "t1.diameter" in response.json()["detail"]
assert (await db_session.execute(select(Measurement))).scalars().all() == []
@pytest.mark.asyncio
async def test_a_task_without_a_graph_is_refused(
client, db_session, measurement_tec_user, worker_says,
@@ -0,0 +1,224 @@
"""#38.7: /api/vision/execute must not run a camera graph against a task that
is not one, a version that is no longer current, an inactive recipe, or a
production run that belongs to someone else's job or is already closed - and
lot_number/serial_number/production_run_id must not reach MySQL unbounded.
"""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.models.api.production import ProductionRunCreate
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.services import production_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
def an_image() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
return buffer.getvalue()
async def recipe_parts(db_session, recipe_id: int):
version = (await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalars().first()
subtask = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
return version, task, subtask
async def a_camera_task(db_session, recipe_id: int):
version, task, subtask = await recipe_parts(db_session, recipe_id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
await db_session.commit()
await db_session.refresh(task)
await db_session.refresh(subtask)
return version, task, subtask
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
return {"outputs": outputs, "failures": [],
"engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr(
"src.backend.services.vision_service._call_worker", fake_call,
)
return _install
async def _station(db_session, user_id: int, code: str = "ST-VIS") -> Station:
station = Station(code=code, name=f"Stazione {code}", active=True, created_by=user_id)
db_session.add(station)
await db_session.commit()
await db_session.refresh(station)
return station
@pytest.mark.asyncio
async def test_execute_rejects_a_task_that_is_not_a_camera_measurement(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
# left at its fixture default task_type ("measure"), no vision_json
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
assert "camera" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_execute_rejects_a_non_current_version(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await a_camera_task(db_session, recipe.id)
version.is_current = False
await db_session.commit()
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_execute_rejects_an_inactive_recipe(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await a_camera_task(db_session, recipe.id)
recipe.active = False
await db_session.commit()
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_execute_rejects_a_production_run_of_another_recipe(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="VIS-GATE-1")
version, task, subtask = await a_camera_task(db_session, recipe.id)
other_recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="VIS-GATE-2")
station = await _station(db_session, measurement_tec_user.id)
run = await production_service.open_run(
db_session,
ProductionRunCreate(station_code=station.code, recipe_id=other_recipe.id),
measurement_tec_user,
)
await db_session.commit()
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id), "production_run_id": str(run.id)},
)
assert response.status_code == 422
assert "recipe" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_execute_rejects_a_closed_production_run(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="VIS-GATE-3")
version, task, subtask = await a_camera_task(db_session, recipe.id)
station = await _station(db_session, measurement_tec_user.id, code="ST-VIS-2")
run = await production_service.open_run(
db_session,
ProductionRunCreate(station_code=station.code, recipe_id=recipe.id),
measurement_tec_user,
)
await db_session.commit()
await production_service.close_run(
db_session, run, measurement_tec_user, measurement_tec_user,
)
await db_session.commit()
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id), "production_run_id": str(run.id)},
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_execute_rejects_a_lot_number_over_the_column_limit(
client, db_session, measurement_tec_user, worker_says,
):
"""max_length=100: a longer value must be refused before it can 500 a
strict MySQL VARCHAR(100) column."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await a_camera_task(db_session, recipe.id)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id), "lot_number": "L" * 101},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_execute_rejects_a_non_positive_production_run_id(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await a_camera_task(db_session, recipe.id)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id), "production_run_id": "0"},
)
assert response.status_code == 422
+9
View File
@@ -4,6 +4,7 @@ from sqlalchemy import select
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import TASK_TYPES, RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
from src.backend.tests.conftest import create_test_recipe
@@ -69,3 +70,11 @@ def test_the_typo_is_gone():
"""`xf_compare` era un refuso: si corregge finché nessuna riga lo usa."""
assert "dxf_compare" in TASK_TYPES
assert "xf_compare" not in TASK_TYPES
@pytest.mark.parametrize("model", [VisionResult, VisionReferenceImage])
def test_task_id_cascades_when_the_task_is_deleted(model):
"""#38.6: cancellare un task con esecuzioni/immagini non deve fallire su
un vincolo FK MySQL - la riga di visione deve seguirlo."""
(fk,) = model.__table__.columns["task_id"].foreign_keys
assert fk.ondelete == "CASCADE"
+179
View File
@@ -85,6 +85,185 @@ async def test_a_reference_image_keeps_its_provenance(
assert saved.path
@pytest.mark.asyncio
async def test_the_file_survives_a_failed_commit_as_nothing(
client, db_session, maker_user, monkeypatch,
):
"""#38.5: the file is written only after a successful flush, and removed
if the commit that follows still fails - never an orphan on disk."""
recipe = await create_test_recipe(db_session, maker_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
await db_session.commit()
original_commit = db_session.commit
async def boom(*args, **kwargs):
raise RuntimeError("simulated commit failure")
monkeypatch.setattr(db_session, "commit", boom)
try:
# The ASGI transport re-raises an unhandled app exception rather
# than turning it into a response - the same way it would surface
# in production logs. What matters here is what is left on disk.
with pytest.raises(RuntimeError, match="simulated commit failure"):
await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("ref.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
finally:
# The db_session fixture commits again on its own teardown - restore
# the real commit so that does not also blow up.
monkeypatch.setattr(db_session, "commit", original_commit)
folder = settings.upload_path / "vision" / "reference" / str(task.id)
assert not folder.exists() or list(folder.iterdir()) == []
@pytest.mark.asyncio
async def test_reference_images_are_listed_for_their_task(
client, db_session, maker_user,
):
recipe = await create_test_recipe(db_session, maker_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
await db_session.commit()
for _ in range(2):
response = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("ref.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 200
listing = await client.get(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
params={"task_id": task.id},
)
assert listing.status_code == 200
body = listing.json()
assert len(body) == 2
assert {row["task_id"] for row in body} == {task.id}
@pytest.mark.asyncio
async def test_deleting_a_reference_image_removes_the_row_and_the_file(
client, db_session, maker_user,
):
recipe = await create_test_recipe(db_session, maker_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
await db_session.commit()
created = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("ref.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
reference_id = created.json()["id"]
file_path = settings.upload_path / created.json()["path"]
assert file_path.exists()
response = await client.delete(
f"/api/vision/reference-images/{reference_id}",
headers=auth_headers(maker_user),
)
assert response.status_code == 204
assert (await db_session.execute(
select(VisionReferenceImage).where(VisionReferenceImage.id == reference_id)
)).scalar_one_or_none() is None
assert not file_path.exists()
@pytest.mark.asyncio
async def test_deleting_an_unknown_reference_image_is_404(
client, db_session, maker_user,
):
response = await client.delete(
"/api/vision/reference-images/999999",
headers=auth_headers(maker_user),
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_copy_on_write_carries_over_reference_images(
client, db_session, maker_user, measurement_tec_user,
):
"""#38.6: a copy-on-write version must list the source task's reference
images (same path, same metadata), and the file is removed only once no
row references it any more."""
recipe = await create_test_recipe(db_session, maker_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
await db_session.commit()
created = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("ref.png", an_image(), "image/png")},
data={"task_id": str(task.id), "note": "orig"},
)
assert created.status_code == 200
old_reference_id = created.json()["id"]
path = created.json()["path"]
file_path = settings.upload_path / path
assert file_path.exists()
# A measurement on v1 forces the next PUT onto the copy-on-write path.
measured = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={"subtask_id": subtask.id, "version_id": version.id, "value": 10.0},
)
assert measured.status_code == 200
resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"change_notes": "Edit after measurement"},
)
assert resp.status_code == 200, resp.text
db_session.expunge_all()
new_version, new_task, _ = await recipe_parts(db_session, recipe.id)
assert new_version.version_number == 2
listing = await client.get(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
params={"task_id": new_task.id},
)
assert listing.status_code == 200
body = listing.json()
assert len(body) == 1
assert body[0]["path"] == path
assert body[0]["note"] == "orig"
new_reference_id = body[0]["id"]
assert new_reference_id != old_reference_id
# Two rows share the same file: deleting one must leave it in place.
del1 = await client.delete(
f"/api/vision/reference-images/{old_reference_id}",
headers=auth_headers(maker_user),
)
assert del1.status_code == 204
assert file_path.exists()
# Deleting the last row referencing the path removes the file too.
del2 = await client.delete(
f"/api/vision/reference-images/{new_reference_id}",
headers=auth_headers(maker_user),
)
assert del2.status_code == 204
assert not file_path.exists()
@pytest.mark.asyncio
async def test_preview_runs_the_graph_and_saves_no_measurement(
client, db_session, maker_user, worker_says,
@@ -102,6 +102,32 @@ async def test_execute_rejects_a_non_image_upload(
assert (await db_session.execute(select(Measurement))).scalars().all() == []
@pytest.mark.asyncio
async def test_execute_rejects_a_file_whose_bytes_do_not_match_its_declared_type(
client, db_session, measurement_tec_user, worker_says,
):
"""#15: a text file declared image/png passes the Content-Type check but
must still not reach the worker - it is refused on its magic bytes."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
await db_session.commit()
worker_says({"t1.diameter": float(subtask.nominal)})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", b"i am not an image", "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
assert (await db_session.execute(select(VisionResult))).scalars().all() == []
assert (await db_session.execute(select(Measurement))).scalars().all() == []
@pytest.mark.asyncio
async def test_execute_rejects_an_oversized_upload(
client, db_session, measurement_tec_user, worker_says, monkeypatch,
@@ -115,3 +115,75 @@ async def test_a_well_formed_response_still_passes_through(monkeypatch):
report = await _call_worker(b"img", {"schema_version": 1, "tools": []})
assert report == body
# ---------------------------------------------------------------------------
# #14: the worker dying mid-response (RemoteProtocolError) or a connect
# timeout (ConnectTimeout, an httpx.TimeoutException) used to fall through
# every except clause here and surface as a naked 500.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_the_worker_dying_mid_response_answers_502_not_a_naked_500(monkeypatch):
_install(
monkeypatch,
raises=httpx.RemoteProtocolError(
"Server disconnected without sending a response."
),
)
with pytest.raises(HTTPException) as excinfo:
await _call_worker(b"img", {"schema_version": 1, "tools": []})
assert excinfo.value.status_code == 502
assert "unreachable" in excinfo.value.detail.lower()
@pytest.mark.asyncio
async def test_a_connect_timeout_answers_504(monkeypatch):
"""ConnectTimeout is an httpx.TimeoutException, not caught by name before -
only httpx.ReadTimeout was."""
_install(monkeypatch, raises=httpx.ConnectTimeout("connect timed out"))
with pytest.raises(HTTPException) as excinfo:
await _call_worker(b"img", {"schema_version": 1, "tools": []})
assert excinfo.value.status_code == 504
assert "time" in excinfo.value.detail.lower()
# ---------------------------------------------------------------------------
# #15: a 422 from the worker (bad image, malformed graph) must reach the
# Maker as a 422 with the worker's own reason, not a flattened 502.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_a_422_from_the_worker_propagates_as_422_with_its_detail(monkeypatch):
_install(
monkeypatch,
response=_FakeResponse(
422, body={"detail": "image is not decodable: cannot identify image file"},
),
)
with pytest.raises(HTTPException) as excinfo:
await _call_worker(b"img", {"schema_version": 1, "tools": []})
assert excinfo.value.status_code == 422
assert "not decodable" in excinfo.value.detail
@pytest.mark.asyncio
async def test_a_422_from_the_worker_without_json_body_still_propagates(monkeypatch):
_install(
monkeypatch,
response=_FakeResponse(422, text="plain text reason", raise_on_json=True),
)
with pytest.raises(HTTPException) as excinfo:
await _call_worker(b"img", {"schema_version": 1, "tools": []})
assert excinfo.value.status_code == 422
assert excinfo.value.detail == "plain text reason"
View File
+34
View File
@@ -0,0 +1,34 @@
"""Time helpers: the DB holds naive UTC, presentation converts to display_timezone (#42)."""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from src.backend.config import settings
def utcnow() -> datetime:
"""Naive UTC now (the whole DB moves to naive UTC, #42)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def as_utc_naive(dt: datetime | None) -> datetime | None:
"""Aware -> UTC naive; naive is taken as UTC already; None stays None."""
if dt is None or dt.tzinfo is None:
return dt
return dt.astimezone(timezone.utc).replace(tzinfo=None)
def to_utc_aware(dt: datetime) -> datetime:
"""Naive (UTC) or aware datetime -> aware UTC."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def iso_utc(dt: datetime | None) -> str:
"""ISO 8601 with explicit +00:00; "" for None (CSV cells)."""
return to_utc_aware(dt).isoformat() if dt else ""
def to_local(dt: datetime) -> datetime:
"""Naive UTC (or aware) datetime -> aware datetime in settings.display_timezone."""
return to_utc_aware(dt).astimezone(ZoneInfo(settings.display_timezone))
-37
View File
@@ -1,37 +0,0 @@
# FastAPI + ASGI
fastapi>=0.110.0
uvicorn[standard]>=0.30.0
# Database
sqlalchemy[asyncio]>=2.0.0
asyncmy>=0.2.0
alembic>=1.13.0
# Validation
pydantic>=2.0.0
pydantic-settings>=2.0.0
# Security
bcrypt>=4.0.0
# File handling
pillow>=10.0.0
python-multipart>=0.0.6
# Templating (setup page)
jinja2>=3.1.0
# Reports
plotly>=5.0.0
kaleido>=0.2.0
weasyprint>=62.0
# Utilities
python-dotenv>=1.0.0
# Testing
pytest>=8.0.0
pytest-asyncio>=0.23.0
httpx>=0.27.0
aiosqlite>=0.20.0
coverage>=7.0.0
+78 -5
View File
@@ -2,16 +2,17 @@
import json
import os
import re
from datetime import date
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from urllib.parse import urlparse
from flask import Flask, redirect, url_for, session, request
from flask_babel import Babel
from flask_wtf.csrf import CSRFProtect
from flask import Flask, flash, g, jsonify, redirect, url_for, session, request
from flask_babel import Babel, gettext as _
from flask_wtf.csrf import CSRFError, CSRFProtect
from markupsafe import Markup, escape
from werkzeug.middleware.proxy_fix import ProxyFix
from config import Config
from config import Config, guard_default_secret_key
# **bold**, the whole of the markup a task description understands besides the
# line break. DOTALL so a phrase that wraps onto the next line still closes.
@@ -80,6 +81,11 @@ def create_app() -> Flask:
app.register_blueprint(statistics_bp, url_prefix="/statistics")
app.register_blueprint(admin_bp, url_prefix="/admin")
@app.before_request
def _check_secret_key():
"""See guard_default_secret_key in config.py (#33)."""
guard_default_secret_key(app)
@app.route("/")
def index():
"""Root redirect to login or dashboard based on session."""
@@ -101,6 +107,36 @@ def create_app() -> Flask:
referrer = None
return redirect(referrer or url_for("auth.login"))
@app.errorhandler(CSRFError)
def handle_csrf_error(error):
"""CSRF token expired or missing (#12): a stale page, not a network failure.
WTF_CSRF_TIME_LIMIT is None so a token only goes bad with the session
itself, but a tab left open across a forced logout (or missing token)
still lands here. Fetch/JSON callers get a body they can branch on
instead of an HTML error page that breaks `response.json()`; page
loads get a flash and a redirect back to where they were.
"""
wants_json = (
request.accept_mimetypes.best_match(["application/json", "text/html"])
== "application/json"
or (request.content_type or "").startswith("application/json")
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
)
if wants_json:
return (
jsonify({"error": True, "csrf_expired": True, "detail": error.description}),
400,
)
flash(_("Sessione scaduta: effettua nuovamente l'accesso."), "error")
referrer = request.referrer
if referrer:
parsed = urlparse(referrer)
if parsed.netloc and parsed.netloc != request.host:
referrer = None
return redirect(referrer or url_for("auth.login"))
@app.template_filter("rich_text")
def rich_text_filter(value):
"""Render a task description keeping its line breaks and its bold.
@@ -119,6 +155,26 @@ def create_app() -> Flask:
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
return Markup(bolded.replace("\n", "<br>"))
@app.template_filter("local_dt")
def local_dt_filter(value, fmt="%d/%m/%Y %H:%M"):
"""Show a UTC datetime (ISO string or datetime) in DISPLAY_TIMEZONE.
A value without timezone is taken as UTC. None or empty gives "-".
"""
if value is None or value == "":
return "-"
if isinstance(value, str):
try:
value = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return value
if not isinstance(value, datetime):
return str(value)
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
tz = ZoneInfo(app.config.get("DISPLAY_TIMEZONE") or "Europe/Rome")
return value.astimezone(tz).strftime(fmt)
@app.template_filter("tojson_attr")
def tojson_attr_filter(value):
"""JSON encode safe for HTML attributes (x-data, etc.).
@@ -156,6 +212,23 @@ def create_app() -> Flask:
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
return response
@app.after_request
def handle_expired_session(response):
"""Turn a 401 from the backend into a redirect, for page loads (#25).
api_client already emptied the session and set ``g.session_expired``
on any 401. JSON/AJAX proxy routes already forward the api_client
dict (which carries ``session_expired``) as their own body, so a
fetch caller has what it needs. What is left is the page views that
swallowed the error (flashed something generic and rendered anyway,
e.g. an empty recipe list): those get replaced here, in the one
place that sees every response, instead of one check per route.
"""
if getattr(g, "session_expired", False) and not response.is_json:
flash(_("Sessione scaduta: effettua nuovamente l'accesso."), "error")
return redirect(url_for("auth.login"))
return response
@app.context_processor
def inject_globals():
"""Inject global variables into all templates."""
+5 -2
View File
@@ -81,7 +81,10 @@ def login():
flash(response.get("detail", _("Credenziali non valide")), "error")
return render_template("auth/login.html")
# Salva in sessione
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
# limite configurato non ha mai effetto (#33).
session.permanent = True
session["api_key"] = response["api_key"]
session["user"] = response["user"]
session["user_id"] = response["user"]["id"]
@@ -118,7 +121,7 @@ def login():
return render_template("auth/login.html")
@auth_bp.route("/logout", methods=["GET", "POST"])
@auth_bp.route("/logout", methods=["POST"])
def logout():
"""Clear session and redirect to login."""
# Invalida il token sul server
+166 -26
View File
@@ -1,6 +1,8 @@
"""MeasurementTec blueprint - recipe selection and measurement execution."""
import urllib.parse
from flask import (
Blueprint, flash, jsonify, redirect, render_template,
Blueprint, abort, flash, jsonify, redirect, render_template,
request, session, url_for,
)
from flask_babel import gettext as _
@@ -182,6 +184,32 @@ def _current_run_id() -> int | None:
return resp.get("id")
def _recipe_allowed_at_station(recipe_id: int) -> bool:
"""Is this recipe among the ones the picker shows for the current station? (#24)
The picker (/measure/select) already filters through
get_station_recipes(); barcode lookup, the guided-sequence shortcut and a
direct link into task_execute all bypass that page, so without this check
an operator could measure a recipe never assigned to this station just by
knowing (or scanning) its code. Same source of truth, same call - no
separate policy to keep in sync. Anything we cannot confirm (no station
configured, the call failing) is refused, not let through.
"""
station_code, _overridden = _current_station()
if not station_code:
return False
try:
resp = api_client.get_station_recipes(station_code)
except Exception:
return False
if isinstance(resp, dict) and resp.get("error"):
return False
recipes = resp.get("items", resp) if isinstance(resp, dict) else resp
if not isinstance(recipes, list):
return False
return any(isinstance(r, dict) and r.get("id") == recipe_id for r in recipes)
# ---------------------------------------------------------------------------
# Route: start the guided sequence
# ---------------------------------------------------------------------------
@@ -207,6 +235,9 @@ def start_sequence(recipe_id: int):
)
return redirect(url_for("measure.select_recipe"))
if not _recipe_allowed_at_station(recipe_id):
abort(403, description=_("Ricetta non assegnata a questa stazione"))
missing = _missing_traceability(recipe_resp, lot_number, serial_number)
if missing:
flash(
@@ -281,11 +312,14 @@ def task_execute(task_id: int):
)
return redirect(url_for("measure.select_recipe"))
recipe_id = task_resp.get("recipe_id")
if recipe_id and not _recipe_allowed_at_station(recipe_id):
abort(403, description=_("Ricetta non assegnata a questa stazione"))
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
# Load all task IDs for this recipe (ordered) for auto-advance
recipe_id = task_resp.get("recipe_id")
all_task_ids = []
measurement_interval_minutes = None
# Allowed until the recipe says otherwise: a recipe that could not be read must
@@ -322,6 +356,8 @@ def task_complete(recipe_id: int):
"""Task completion summary with all measurements."""
# Retrieve version_id from query params
version_id = request.args.get("version_id")
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
# Load recipe for context
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
@@ -350,30 +386,50 @@ def task_complete(recipe_id: int):
# Load measurements if version_id provided
measurements = []
if version_id:
meas_resp = api_client.get(
"/api/measurements",
params={"version_id": version_id, "per_page": 500},
)
if not (isinstance(meas_resp, dict) and meas_resp.get("error")):
raw = (
# Scoped to this measurement session, not the whole version (#30): the
# active production run when known, else lot/serial + current operator.
params = {"version_id": version_id, "per_page": 500}
run_id = _current_run_id()
if run_id:
params["production_run_id"] = run_id
if lot_number:
params["lot_number"] = lot_number
if serial_number:
params["serial_number"] = serial_number
user_id = session.get("user_id")
if user_id:
params["measured_by"] = user_id
raw = []
page = 1
while True:
meas_resp = api_client.get(
"/api/measurements", params={**params, "page": page},
)
if isinstance(meas_resp, dict) and meas_resp.get("error"):
break
page_items = (
meas_resp if isinstance(meas_resp, list)
else meas_resp.get("items", [])
)
# Enrich each measurement with nested subtask data
for m in raw:
st = subtask_map.get(m.get("subtask_id"), {})
m["subtask"] = st
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
# Compute deviation if not present
if m.get("deviation") is None and st.get("nominal") is not None:
try:
m["deviation"] = m["value"] - st["nominal"]
except (TypeError, KeyError):
m["deviation"] = 0.0
measurements = raw
raw.extend(page_items)
total_pages = meas_resp.get("pages", 1) if isinstance(meas_resp, dict) else 1
if page >= total_pages or not page_items:
break
page += 1
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
# Enrich each measurement with nested subtask data
for m in raw:
st = subtask_map.get(m.get("subtask_id"), {})
m["subtask"] = st
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
# Compute deviation if not present
if m.get("deviation") is None and st.get("nominal") is not None:
try:
m["deviation"] = m["value"] - st["nominal"]
except (TypeError, KeyError):
m["deviation"] = 0.0
measurements = raw
return render_template(
"measure/task_complete.html",
@@ -393,18 +449,33 @@ def task_complete(recipe_id: int):
def lookup_barcode():
"""Look up a recipe by barcode/code. Returns JSON for AJAX calls."""
data = request.get_json(silent=True) or {}
code = data.get("code", "").strip()
code = data.get("code", "")
# #37.4: a non-string "code" (e.g. {"code": 5}) crashed .strip() into a
# 500. A slash in the code (or "../") used to walk straight into the URL
# path unescaped - "AB/123" split into segments the backend never
# matched, and "../" could reach another GET endpoint with this user's
# permissions.
if not isinstance(code, str):
return jsonify({"error": True, "detail": _("Codice non valido")}), 400
code = code.strip()
if not code:
return jsonify({"error": True, "detail": _("Codice non fornito")}), 400
resp = api_client.get(f"/api/recipes/code/{code}")
resp = api_client.get(f"/api/recipes/code/{urllib.parse.quote(code, safe='')}")
if resp.get("error"):
return jsonify({
"error": True,
"detail": resp.get("detail", _("Ricetta non trovata")),
}), 404
if not _recipe_allowed_at_station(resp.get("id")):
return jsonify({
"error": True,
"detail": _("Ricetta non assegnata a questa stazione"),
}), 403
return jsonify(resp)
@@ -481,14 +552,83 @@ def save_measurement():
if resp.get("error"):
status_code = resp.get("status_code", 500)
return jsonify({
error_body = {
"error": True,
"detail": resp.get("detail", _("Errore nel salvataggio")),
}), status_code if status_code >= 400 else 500
}
if resp.get("session_expired"):
error_body["session_expired"] = True
return jsonify(error_body), status_code if status_code >= 400 else 500
return jsonify(resp), 201
# ---------------------------------------------------------------------------
# Route: task progress (#37.8) - browser cannot call the backend directly
# (X-API-Key), same as every other /api/* proxy in this file.
# ---------------------------------------------------------------------------
@measure_bp.route("/api/task-progress/<int:task_id>", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_task_progress(task_id: int):
"""Proxy: how many quotes of this task are already measured.
Reuses _task_progress() - same version_id/production_run_id scoping as
task_list.html's badges - so this answers with exactly the counts
(quotes/measured/state) the backend can give without conflating one
cycle with another; it does not, and cannot, say which quotes or with
what values (task-progress is an aggregate, not a per-subtask list).
"""
task_resp = api_client.get(f"/api/tasks/{task_id}")
if isinstance(task_resp, dict) and task_resp.get("error"):
return jsonify(task_resp), task_resp.get("status_code", 500)
recipe_id = task_resp.get("recipe_id")
if recipe_id and not _recipe_allowed_at_station(recipe_id):
abort(403, description=_("Ricetta non assegnata a questa stazione"))
version_id = task_resp.get("version_id")
progress = _task_progress(version_id, _current_run_id())
row = progress.get(task_id, {
"task_id": task_id, "quotes": 0, "measured": 0, "state": "empty",
})
return jsonify(row), 200
# ---------------------------------------------------------------------------
# Route: quotes already measured in the current cycle (#40)
# ---------------------------------------------------------------------------
@measure_bp.route("/api/current-cycle/<int:task_id>", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_current_cycle(task_id: int):
"""Proxy: quotes of this task already measured in the cycle under way.
The run comes from _current_run_id(), like the rest of the page. Outside a
production there is no cycle to restore and the answer is an empty list.
"""
task_resp = api_client.get(f"/api/tasks/{task_id}")
if isinstance(task_resp, dict) and task_resp.get("error"):
return jsonify(task_resp), task_resp.get("status_code", 500)
recipe_id = task_resp.get("recipe_id")
if recipe_id and not _recipe_allowed_at_station(recipe_id):
abort(403, description=_("Ricetta non assegnata a questa stazione"))
run_id = _current_run_id()
if not run_id:
return jsonify({"measurements": []}), 200
resp = api_client.get(
"/api/measurements/current-cycle",
params={"production_run_id": run_id, "task_id": task_id},
)
if not isinstance(resp, dict) or resp.get("error"):
# Never fatal: without the restore the screen behaves as it always did.
return jsonify({"measurements": []}), 200
return jsonify(resp), 200
# ---------------------------------------------------------------------------
# Routes: Out-of-tolerance authorisation (point 5)
# ---------------------------------------------------------------------------
@@ -20,11 +20,22 @@ statistics_bp = Blueprint("statistics", __name__)
@role_required("Metrologist")
def dashboard():
"""SPC dashboard overview — loads recipes for filter dropdown."""
resp = api_client.get("/api/recipes", params={"per_page": 100})
if isinstance(resp, dict) and resp.get("error"):
recipes = []
else:
recipes = resp.get("items", []) if isinstance(resp, dict) else []
# #37.6: a single per_page=100 call silently dropped every recipe past
# the 100th from the filter dropdown. 100 is the backend's max per_page
# (routers/recipes.py), so a full listing needs a page loop, not a
# bigger number.
recipes = []
page = 1
while True:
resp = api_client.get("/api/recipes", params={"per_page": 100, "page": page})
if isinstance(resp, dict) and resp.get("error"):
break
page_items = resp.get("items", []) if isinstance(resp, dict) else []
recipes.extend(page_items)
total_pages = resp.get("pages", 1) if isinstance(resp, dict) else 1
if page >= total_pages or not page_items:
break
page += 1
return render_template("statistics/dashboard.html", recipes=recipes)
@@ -48,7 +59,10 @@ def _proxy_statistics(endpoint: str):
resp = api_client.get(f"/api/statistics/{endpoint}", params=params)
if isinstance(resp, dict) and resp.get("error"):
return jsonify({"error": True, "detail": resp.get("detail", "")}), resp.get("status_code", 500)
error_body = {"error": True, "detail": resp.get("detail", "")}
if resp.get("session_expired"):
error_body["session_expired"] = True
return jsonify(error_body), resp.get("status_code", 500)
return jsonify(resp)
@@ -101,7 +115,10 @@ def api_subtasks():
resp = api_client.get("/api/statistics/subtasks", params=params)
if isinstance(resp, dict) and resp.get("error"):
return jsonify({"error": True, "detail": resp.get("detail", "")}), resp.get("status_code", 500)
error_body = {"error": True, "detail": resp.get("detail", "")}
if resp.get("session_expired"):
error_body["session_expired"] = True
return jsonify(error_body), resp.get("status_code", 500)
# Response is a list
if isinstance(resp, list):
+43 -3
View File
@@ -4,12 +4,17 @@ from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
# Committed default for CLIENT_SECRET_KEY (also in .env.example). A Flask
# session is only as secret as this value: left at the default, anyone can
# forge a session cookie (roles, is_admin) - see guard_default_secret_key (#33).
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
class Config:
"""Flask client configuration."""
# Flask
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", "change-this-to-another-random-secret-key")
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
# API Server connection
@@ -18,9 +23,23 @@ class Config:
# Session
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = not DEBUG # Only secure cookies in production (HTTPS)
# Default: secure unless FLASK_DEBUG=1 (unchanged). Explicit override via
# SESSION_COOKIE_SECURE=false for the on-premise install (#38.4): nginx
# there listens on plain HTTP inside the LAN, and a cookie marked Secure
# is silently dropped by the browser on a non-HTTPS origin - login looks
# like it works and the session never sticks.
_session_cookie_secure_env = os.getenv("SESSION_COOKIE_SECURE")
if _session_cookie_secure_env is not None:
SESSION_COOKIE_SECURE = _session_cookie_secure_env.strip().lower() not in (
"false", "0", "no",
)
else:
SESSION_COOKIE_SECURE = not DEBUG
PERMANENT_SESSION_LIFETIME = 28800 # 8 hours
WTF_CSRF_TIME_LIMIT = 3600 # 1 hour
# None = token valid for the life of the session (#12): a measurement page
# stays open for hours between saves, and a 1h-expired token turned every
# late save into a silent "Errore di rete" for the operator.
WTF_CSRF_TIME_LIMIT = None
# Station identity: each deployed client container sets this to the station
# code it belongs to. Empty/None means "not configured".
@@ -37,4 +56,25 @@ class Config:
# Babel i18n
BABEL_DEFAULT_LOCALE = "it"
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
# Il DB e le API parlano UTC; si converte solo alla presentazione.
DISPLAY_TIMEZONE: str = os.getenv("DISPLAY_TIMEZONE", "Europe/Rome")
LANGUAGES = {"it": "Italiano", "en": "English"}
def guard_default_secret_key(app) -> None:
"""Refuse to serve when CLIENT_SECRET_KEY was left at its committed default (#33).
Checked per-request (wired as a ``before_request`` in app.py) rather than
at import/create_app time: pytest and every fixture in this suite call
``create_app()`` and only *afterwards* set ``app.config["TESTING"]``, so a
check made during app creation would always see TESTING as False and
break the whole test suite and local `flask run` alike.
"""
if app.debug or app.testing:
return
if app.config.get("SECRET_KEY") == DEFAULT_SECRET_KEY:
raise RuntimeError(
"CLIENT_SECRET_KEY non impostato: la sessione Flask (ruoli, is_admin) "
"sarebbe falsificabile da chiunque conosca il default committato. "
"Imposta CLIENT_SECRET_KEY in .env prima di avviare in produzione."
)
+32 -6
View File
@@ -2,7 +2,7 @@
from typing import Any
import requests
from flask import has_request_context, request, session
from flask import g, has_request_context, request, session
from config import Config
@@ -39,6 +39,21 @@ class APIClient:
headers["X-Real-IP"] = client_ip
return headers
@staticmethod
def _handle_session_expired() -> None:
"""The backend says our api_key is no longer valid (#25).
One point for the whole app: whichever route made the call, the
Flask session is emptied here so nothing downstream keeps acting as
that user. A flag is left on ``g`` for the single after_request
hook in app.py to turn into a redirect-to-login on HTML pages; JSON
callers already get ``session_expired`` in the body returned below.
"""
if not has_request_context():
return
session.clear()
g.session_expired = True
def _handle_response(self, response: requests.Response) -> dict[str, Any]:
"""Parse response and return normalized dict.
@@ -46,12 +61,16 @@ class APIClient:
- If 2xx and not 204: response JSON
- If 204 No Content: empty dict
- If 4xx/5xx: {"error": True, "status_code": ..., "detail": "..."}
(401 also carries "session_expired": True)
"""
if response.ok:
if response.status_code == 204:
return {}
return response.json()
if response.status_code == 401:
self._handle_session_expired()
# Non-OK response (4xx/5xx)
try:
error_body = response.json()
@@ -74,11 +93,14 @@ class APIClient:
except Exception:
detail = response.text or f"HTTP {response.status_code}"
return {
result = {
"error": True,
"status_code": response.status_code,
"detail": detail
}
if response.status_code == 401:
result["session_expired"] = True
return result
def get(self, endpoint: str, params: dict | None = None) -> dict[str, Any]:
"""GET request to API server."""
@@ -91,9 +113,13 @@ class APIClient:
)
return self._handle_response(response)
except (requests.ConnectionError, requests.Timeout) as e:
# 502, not 0 (#37.1): a route that forwards status_code straight to
# jsonify(...), status_code was answering with a made-up HTTP status
# that Werkzeug doesn't know ("0 UNKNOWN"), instead of a real one a
# fetch caller can branch on.
return {
"error": True,
"status_code": 0,
"status_code": 502,
"detail": f"Errore di connessione al server: {str(e)}"
}
@@ -124,7 +150,7 @@ class APIClient:
except (requests.ConnectionError, requests.Timeout) as e:
return {
"error": True,
"status_code": 0,
"status_code": 502,
"detail": f"Errore di connessione al server: {str(e)}"
}
@@ -141,7 +167,7 @@ class APIClient:
except (requests.ConnectionError, requests.Timeout) as e:
return {
"error": True,
"status_code": 0,
"status_code": 502,
"detail": f"Errore di connessione al server: {str(e)}"
}
@@ -157,7 +183,7 @@ class APIClient:
except (requests.ConnectionError, requests.Timeout) as e:
return {
"error": True,
"status_code": 0,
"status_code": 502,
"detail": f"Errore di connessione al server: {str(e)}"
}
@@ -68,6 +68,12 @@ function annotationEditor() {
_bgImageNatW: 0,
_bgImageNatH: 0,
/** Background image scale applied at the last _fitCanvasToImage() call
* (#28) - the ratio between this and the newly computed scale is how
* existing annotation objects get repositioned/resized on a resize,
* instead of being left at their old pixel coordinates. */
_currentScale: 0,
/** Annotations JSON to load after background image is ready */
_pendingAnnotations: null,
@@ -218,6 +224,16 @@ function annotationEditor() {
this._debouncedResize = debounce(this.resizeCanvas.bind(this), 200);
window.addEventListener('resize', this._debouncedResize);
// Unsaved annotations are easy to lose by accident (a stray back-nav,
// an accidental tab close) - isDirty already tracks exactly that (#28).
this._onBeforeUnload = function (e) {
if (!self.isDirty) return;
e.preventDefault();
e.returnValue = '';
return '';
};
window.addEventListener('beforeunload', this._onBeforeUnload);
// ---- Bridge: listen for commands from recipeEditor ----
this._onToolChange = function (e) {
@@ -471,6 +487,15 @@ function annotationEditor() {
* so there is zero empty space and canvas coords map 1:1 to
* scaled-image coords.
*
* Existing annotation objects are repositioned/resized by the same
* factor the background just changed by (#28): before this, only the
* canvas and the background image were rescaled on a resize
* (window resize, sidebar toggle, ...) - every marker/arrow/area stayed
* at its old pixel coordinates, drifting visually off the drawing, and
* a save right after wrote the *new* imageScale next to coordinates
* that had never actually moved to match it, corrupting the position
* for the operator's viewer too.
*
* @param {fabric.Image} img - Fabric image object (background)
*/
_fitCanvasToImage(img) {
@@ -486,9 +511,12 @@ function annotationEditor() {
if (!natW || !natH) return;
// Scale to fit container width; never upscale
var scale = Math.min(containerWidth / natW, 1);
var canvasW = Math.round(natW * scale);
var canvasH = Math.round(natH * scale);
var newScale = Math.min(containerWidth / natW, 1);
var oldScale = this._currentScale || newScale;
var f = newScale / oldScale;
var canvasW = Math.round(natW * newScale);
var canvasH = Math.round(natH * newScale);
this.canvas.setDimensions({ width: canvasW, height: canvasH });
@@ -496,15 +524,55 @@ function annotationEditor() {
img,
this.canvas.renderAll.bind(this.canvas),
{
scaleX: scale,
scaleY: scale,
scaleX: newScale,
scaleY: newScale,
originX: 'left',
originY: 'top',
}
);
this.canvas.calcOffset();
this.canvas.forEachObject(function (obj) { obj.setCoords(); });
this.canvas.forEachObject(function (obj) {
if (f !== 1) {
obj.left *= f;
obj.top *= f;
if (obj.objectType === 'area') {
// fabric.Rect: getAnnotationsJson serialises obj.width/height
// directly (not width * scaleX), and loadAnnotationsJson
// reconstructs the rect from that same width/height - so the
// size has to travel through width/height too, or a save right
// after this resize would silently drop it.
obj.width = (obj.width || 0) * f;
obj.height = (obj.height || 0) * f;
} else if (obj.objectType === 'text') {
// fabric.IText: same reasoning, fontSize is the serialised field.
var newFontSize = (obj.fontSize || 18) * f;
obj.set('fontSize', newFontSize);
obj.textFontSize = newFontSize;
} else {
// Groups (marker, arrow): their own width/height are never read
// back on load (addMarker/addArrow only use position and, for
// arrows, the endpoints below), so scaleX/scaleY - which drives
// 100% of their rendered size - is what has to change.
obj.scaleX = (obj.scaleX || 1) * f;
obj.scaleY = (obj.scaleY || 1) * f;
}
if (obj.objectType === 'arrow') {
// Stored endpoints live in the same coordinate space as
// obj.left/top (see the object:modified handler, which adds a
// plain pixel delta to them) - so they scale by f exactly like
// left/top do, independently of the group's own scaleX/scaleY.
obj.arrowX1 = (obj.arrowX1 || 0) * f;
obj.arrowY1 = (obj.arrowY1 || 0) * f;
obj.arrowX2 = (obj.arrowX2 || 0) * f;
obj.arrowY2 = (obj.arrowY2 || 0) * f;
}
}
obj.setCoords();
});
this._currentScale = newScale;
this.canvas.renderAll();
},
@@ -1269,6 +1337,9 @@ function annotationEditor() {
if (this._debouncedResize) {
window.removeEventListener('resize', this._debouncedResize);
}
if (this._onBeforeUnload) {
window.removeEventListener('beforeunload', this._onBeforeUnload);
}
if (this._onToolChange) {
window.removeEventListener('anno-tool-change', this._onToolChange);
}
+54 -11
View File
@@ -71,22 +71,38 @@ function csvExport() {
let csv = headers.join(this.delimiter) + '\n';
for (const m of measurements) {
// MeasurementResponse (src/backend/models/api/measurement.py) carries
// none of subtask_name/unit/nominal/tolerance_plus/tolerance_minus -
// that data lives nested under m.subtask (attached server-side in
// measure.py's task_complete(), see subtask/task_info enrichment).
// Reading the flat, nonexistent names is why every one of these
// columns came out empty (#31).
const subtask = m.subtask || {};
const nominal = subtask.nominal;
const tolerancePlus = this.hasNumber(subtask.utl) && this.hasNumber(nominal)
? subtask.utl - nominal : null;
const toleranceMinus = this.hasNumber(nominal) && this.hasNumber(subtask.ltl)
? nominal - subtask.ltl : null;
const row = [
m.id || '',
m.subtask_id || '',
this.escapeCsvValue(m.subtask_name || ''),
this.escapeCsvValue(subtask.description || ''),
this.formatNumber(m.value),
this.escapeCsvValue(m.unit || 'mm'),
this.formatNumber(m.nominal),
this.formatNumber(m.tolerance_plus),
this.formatNumber(m.tolerance_minus),
this.escapeCsvValue(subtask.unit || 'mm'),
this.formatNumber(nominal),
this.formatNumber(tolerancePlus),
this.formatNumber(toleranceMinus),
this.formatNumber(m.deviation),
m.pass_fail || '',
this.escapeCsvValue(m.lot_number || ''),
this.escapeCsvValue(m.serial_number || ''),
m.input_method || '',
this.formatDateTime(m.measured_at),
this.escapeCsvValue(m.operator_name || '')
// No operator name anywhere on this object (or on
// MeasurementResponse) - measured_by is the real field, the
// user's id who took the reading.
this.escapeCsvValue(this.hasNumber(m.measured_by) ? m.measured_by : '')
];
csv += row.join(this.delimiter) + '\n';
}
@@ -163,6 +179,13 @@ function csvExport() {
URL.revokeObjectURL(url);
},
/**
* True for a value that is actually a usable number (not null/undefined/NaN).
*/
hasNumber(value) {
return typeof value === 'number' && !Number.isNaN(value);
},
/**
* Format number with Italian decimal separator
*/
@@ -205,17 +228,37 @@ function csvExport() {
},
/**
* Escape CSV value (handle quotes, delimiters)
* True when the first non-space character opens a formula/command that
* Excel or LibreOffice would evaluate on open (#22): =, +, -, @, TAB, CR.
* Same check as the backend's neutralise_csv_cell
* (production_export_service.py) - only leading ASCII spaces are
* stripped before looking at the first character.
*/
_startsCsvDangerous(str) {
const stripped = str.replace(/^ +/, '');
return stripped.length > 0 && /^[=+\-@\t\r]/.test(stripped);
},
/**
* Escape CSV value (handle quotes, delimiters, formula injection)
*/
escapeCsvValue(value) {
if (value === null || value === undefined) {
return '';
}
const str = String(value);
let str = String(value);
// If contains delimiter, newline, or quote, wrap in quotes
if (str.includes(this.delimiter) || str.includes('\n') || str.includes('"')) {
// Formula/command injection (#22): a leading apostrophe forces the
// cell to stay literal text instead of being evaluated. Only for
// free-text fields - this function is never used on numeric cells
// (those go through formatNumber).
if (this._startsCsvDangerous(str)) {
str = "'" + str;
}
// If contains delimiter, newline, CR, or quote, wrap in quotes
if (str.includes(this.delimiter) || str.includes('\n') || str.includes('\r') || str.includes('"')) {
// Escape existing quotes by doubling them
return '"' + str.replace(/"/g, '""') + '"';
}
@@ -228,7 +271,7 @@ function csvExport() {
*/
generateFilename(prefix) {
const now = new Date();
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // YYYY-MM-DD locale
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
return `${prefix}_${dateStr}_${timeStr}.csv`;
+56 -7
View File
@@ -25,6 +25,8 @@ function numpad(options) {
_lastKeyTime: 0, // Timestamp of last keystroke
_burstCount: 0, // Consecutive fast keystrokes
_enterWasFast: false, // Enter arrived in the same burst as the digits
_rejected: false, // Current reading refused (e.g. overflow); ignore the rest of it
_runStart: null, // {value, gap} snapshot taken at the first key of the current run
/**
* Get the display value with sign
@@ -55,6 +57,8 @@ function numpad(options) {
* @param {string} d - The digit to add (0-9)
*/
addDigit(d) {
if (this._rejected) return;
// Validate: don't exceed max digits
const parts = this.value.split('.');
@@ -63,7 +67,15 @@ function numpad(options) {
if (parts[1] && parts[1].length >= this.maxDecDigits) return;
} else {
// Check integer part length
if (parts[0] && parts[0].length >= this.maxIntDigits) return;
if (parts[0] && parts[0].length >= this.maxIntDigits) {
// A caliper reading whose integer part does not fit must not be
// quietly cut down to something that does: refuse the whole
// reading instead of saving a truncated number. Manual typing
// (outside a burst) keeps the old, harmless behaviour of simply
// not accepting more digits.
if (this._burstCount >= 2) this._rejectReading('integer_overflow');
return;
}
}
// Prevent leading zeros (except "0.")
@@ -79,6 +91,8 @@ function numpad(options) {
* Add a decimal point to the current value
*/
addDecimal() {
if (this._rejected) return;
// Don't add decimal if one already exists
if (this.hasDecimal) return;
@@ -90,10 +104,11 @@ function numpad(options) {
},
/**
* Toggle the sign of the current value
* Toggle the sign of the current value. Works on an empty display too -
* the manual +/- key is pressed before the first digit as often as after
* one, and used to be silently dropped in that case.
*/
toggleSign() {
if (!this.value) return;
this.negative = !this.negative;
},
@@ -119,6 +134,8 @@ function numpad(options) {
this.negative = false;
this.hasDecimal = false;
this._enterWasFast = false;
this._rejected = false;
this._runStart = null;
},
/**
@@ -138,6 +155,7 @@ function numpad(options) {
* Confirm the current value and dispatch event
*/
confirm() {
this._rejected = false;
if (!this.hasValue) return;
const val = this.numericValue;
@@ -239,11 +257,32 @@ function numpad(options) {
this._enterWasFast = gap < 80;
this.confirm();
}
// Minus sign - toggle sign
// Minus / plus - the caliper's own sign character. Unlike the manual
// +/- key (toggleSign(), bound to the on-screen button) these are
// absolute rather than a toggle: a caliper transmission says which
// sign the reading is, it never flips one already on screen.
else if (e.key === '-') {
e.preventDefault();
this.toggleSign();
this.negative = true;
}
else if (e.key === '+') {
e.preventDefault();
this.negative = false;
}
},
/**
* Refuse the reading in progress: say why with an event the page can show,
* and wipe the display so the rejected digits do not linger as if valid.
* Further keystrokes of the same burst are ignored until Enter or a clear.
*/
_rejectReading(reason) {
this.$dispatch('numpad-error', { reason: reason });
this.value = '';
this.negative = false;
this.hasDecimal = false;
this._enterWasFast = false;
this._rejected = true;
},
/**
@@ -253,12 +292,22 @@ function numpad(options) {
*/
_trackBurst() {
const now = performance.now();
const gap = now - this._lastKeyTime;
const gap = this._lastKeyTime > 0 ? now - this._lastKeyTime : Infinity;
if (this._lastKeyTime > 0 && gap < 80) {
if (gap < 80) {
this._burstCount++;
// The burst is only recognisable from its second character. If the
// pause before the first one was long enough to be a human keystroke,
// whatever was on display before this run began was typed by hand:
// drop it now instead of letting the caliper's own digits
// concatenate onto it.
if (this._burstCount === 2 && this._runStart && this._runStart.gap > 300) {
this.value = this.value.slice(this._runStart.value.length);
this.hasDecimal = this.value.includes('.');
}
} else {
this._burstCount = 1;
this._runStart = { value: this.value, gap: gap };
}
this._lastKeyTime = now;

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