Compare commits
32 Commits
bde8fafd77
..
V3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 39a6f62089 | |||
| f20b92bafb | |||
| 6bfe3a5d1a | |||
| 5717b3e7aa | |||
| 85d7976f4e | |||
| a72e3f11af | |||
| 46566ebfa5 | |||
| 0bd95367bb | |||
| 38ef0587f5 | |||
| 533edc5be8 | |||
| 3870cc3b0f | |||
| 398af016f8 | |||
| b86eeada86 | |||
| ddf7788d77 | |||
| 05f2937611 | |||
| 71da162e1f | |||
| 9387e7c306 | |||
| 0a3d1f5222 | |||
| c5f3366dd1 | |||
| 9546ded1e8 | |||
| b578ac9e1b | |||
| cb0fab8c92 | |||
| 767bede43b | |||
| 245975791f | |||
| e0f32fe8a9 | |||
| 2101b9b2c9 | |||
| c4a429d952 | |||
| 78816dfe8c | |||
| 98b1c5ae85 | |||
| 919ee7ac79 | |||
| e2aae88858 | |||
| 5aa3d595ad |
@@ -30,6 +30,15 @@ STATION_CODE=ST-DEFAULT
|
||||
# another station's recipes would silently break traceability.
|
||||
STATION_SWITCH_ENABLED=0
|
||||
|
||||
# --- Vision ---
|
||||
# Internal address of the vision worker. Never exposed outside tmflow-net.
|
||||
VISION_WORKER_URL=http://vision:8100
|
||||
# The pinned VisionSuite commit, stamped into the vision image at build time
|
||||
# (Dockerfile.vision ARG). Required: `docker compose build vision` fails
|
||||
# immediately without it. Set with:
|
||||
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD)
|
||||
VISION_ENGINE_VERSION=
|
||||
|
||||
# --- File Storage ---
|
||||
# Resolved against the project root in src/backend/config.py.
|
||||
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
|
||||
|
||||
@@ -39,6 +39,9 @@ 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
|
||||
@@ -74,3 +77,6 @@ nul
|
||||
# Competitor analysis (local only)
|
||||
Concorrente/
|
||||
docker-compose.override.yml
|
||||
|
||||
# Subagent-driven development scratch: ledger, briefs, review packages
|
||||
.superpowers/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "vendor/visionsuite"]
|
||||
path = vendor/visionsuite
|
||||
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git
|
||||
+7
-1
@@ -20,8 +20,14 @@ RUN uv sync --frozen --no-dev --extra client
|
||||
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 && \
|
||||
RUN npm install tailwindcss@3.4.19 && \
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
|
||||
# Compile Flask-Babel translation catalogs.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
# opencv-python (pulled in transitively by vs-core/vs-task/vs-measure/vs-pm2d)
|
||||
# needs libGL and glib at runtime even though it is never imported by name here.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgl1 libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY vendor/ ./vendor/
|
||||
RUN uv sync --extra vision-worker --frozen --no-dev
|
||||
|
||||
COPY src/vision/ ./src/vision/
|
||||
COPY src/vision_worker/ ./src/vision_worker/
|
||||
|
||||
# Ruling R8: the version is stamped at build time, not discovered at runtime.
|
||||
# COPY vendor/ above does not bring vendor/visionsuite/.git along - it is a
|
||||
# gitlink file pointing at ../../.git/modules/vendor/visionsuite, which lives
|
||||
# outside the build context - so `git rev-parse HEAD` cannot work in here.
|
||||
# Pass the submodule's commit in from the host, which does have git:
|
||||
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
# docker compose build vision
|
||||
ARG VISION_ENGINE_VERSION
|
||||
# I4: a build without the commit must fail now, at build time - not later, at
|
||||
# every request including /health, with a container Docker still reports as
|
||||
# healthy because no healthcheck existed to say otherwise.
|
||||
RUN test -n "$VISION_ENGINE_VERSION" || { \
|
||||
echo "VISION_ENGINE_VERSION is required. Build with:"; \
|
||||
echo ' VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) docker compose build vision'; \
|
||||
exit 1; \
|
||||
}
|
||||
ENV VISION_ENGINE_VERSION=${VISION_ENGINE_VERSION}
|
||||
|
||||
EXPOSE 8100
|
||||
|
||||
# Two workers, not four: each holds the vision stack in memory.
|
||||
CMD ["uv", "run", "uvicorn", "src.vision_worker.main:app", \
|
||||
"--host", "0.0.0.0", "--port", "8100", "--workers", "2"]
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate, identità per-stazione e rate limiting per-tablet.
|
||||
|
||||
> **Versione corrente:** V2.0.0 (in sviluppo) — branch default `V2.0.0`.
|
||||
> **Versione corrente:** V3.0.0 (in sviluppo) — branch `V3.0.0`.
|
||||
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
|
||||
> V3.0.0 lavora sui quindici punti di `TieMeasureFlow_modifiche_2026-07-28.md`: vedi [Novità di V3.0.0](#novità-di-v300).
|
||||
|
||||
---
|
||||
|
||||
@@ -25,6 +26,32 @@ Caratteristiche principali:
|
||||
|
||||
---
|
||||
|
||||
## Novità di V3.0.0
|
||||
|
||||
V3.0.0 lavora sui quindici punti raccolti in `TieMeasureFlow_modifiche_2026-07-28.md`,
|
||||
dal sopralluogo del 28/07. Quanto segue è **fatto e in esercizio**.
|
||||
|
||||
| # | Punto | Cosa cambia |
|
||||
|---|---|---|
|
||||
| 1 · 6 | **Produzione con una vita propria** | `production_runs` e `production_events` sul server: avvio, fermo linea, ripresa e fine produzione esistono come stato, non come pulsanti. La fine produzione emette il file di statistica dell'intera produzione |
|
||||
| 2 | **Tipo di task dichiarato** | `task_type` (`note`, `measure`, `drawing`, `xf_compare`, `camera_measure`) al posto di «ha subtask, quindi è una misura»: un task di misura senza quote non si comporta più come una nota |
|
||||
| 3 | **Ciclo di misura** | L'intervallo della ricetta vive sul server, sopravvive al cambio pagina, conta anche il ritardo (in rosso, oltre lo zero) e riporta l'operatore alla misura. Cicalino via WebAudio |
|
||||
| 5 | **Fuori tolleranza vincolante** | Una quota fuori tolleranza blocca l'avanzamento finché il capoturno non autorizza — o finché quella stessa quota non viene rimisurata dentro i limiti. L'autorizzazione resta scritta sulla misura e nel CSV |
|
||||
| 7 | **Stazioni** | Ricette per stazione, cambio stazione da URL per il collaudo, reset per riga |
|
||||
| 8 | **Tracciabilità obbligatoria** | `requires_lot` / `requires_serial` per ricetta, verificati sul server: nessuna porta d'ingresso li aggira, barcode compreso |
|
||||
| 9 | **Blocco inserimento manuale** | `allow_manual_input`: con la ricetta a solo calibro il tastierino non viene disegnato affatto, e il server rifiuta comunque un valore digitato a mano |
|
||||
| 10 | **Sequenza per l'operatore** | La ricetta si apre sul primo task, non su un elenco. La lista scende a secondo livello e dice quali task sono rimasti incompiuti (`2/3`). «Fine ciclo misura» è visibile da subito e spento finché mancano quote |
|
||||
| 11 | **Descrizioni formattate** | `**grassetto**` e a capo nelle descrizioni dei task, con marcatura e non HTML: la sanificazione è per costruzione |
|
||||
| 12 | **Funzionamento senza internet** | Tutte le librerie e i font nell'installazione, versioni congelate e verificate per impronta, Content-Security-Policy a sola origine locale |
|
||||
| 14 | **Layout stabile** | Una cornice sola per tutte le viste, allineata alla navbar; spazio della barra di scorrimento sempre riservato; schermata di misura su `100dvh`. Vedi [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) |
|
||||
| 15 | **Statistica separata** | Si registra sempre, si consulta a parte: nessun percorso dell'operatore porta alla statistica (verificato da test) |
|
||||
|
||||
**Aperto**: il punto 4 (numero di tentativi prima del capoturno) attende le risposte
|
||||
del cliente su modalità di autorizzazione e numero di tentativi; il punto 13
|
||||
(generazione task dalla scheda tecnica con l'AI) è fuori offerta.
|
||||
|
||||
---
|
||||
|
||||
## Architettura
|
||||
|
||||
```
|
||||
@@ -67,13 +94,22 @@ Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al bac
|
||||
| Flask | 3.x | Framework web server-side |
|
||||
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
||||
| Jinja2 | incluso in Flask | Template engine |
|
||||
| Alpine.js | 3.x (CDN) | Reattività leggera lato client |
|
||||
| TailwindCSS | 3.x | CSS utility-first |
|
||||
| Plotly.js | CDN | Grafici SPC interattivi |
|
||||
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
||||
| html5-qrcode | CDN | Scanner barcode/QR camera |
|
||||
| Alpine.js | 3.15.12 (locale) | Reattività leggera lato client |
|
||||
| TailwindCSS | 3.4.19 (build) | CSS utility-first, compilato nell'immagine |
|
||||
| Plotly.js | 2.32.0 (locale) | Grafici SPC interattivi |
|
||||
| PDF.js | 3.11.174 (locale) | Visualizzazione disegni PDF, worker incluso |
|
||||
| Fabric.js | 5.3.1 (locale) | Editor annotazioni disegni tecnici |
|
||||
| Inter + JetBrains Mono | woff2 locali | Font UI e numeri |
|
||||
| Flask-Babel | ultima stabile | i18n IT/EN |
|
||||
|
||||
**Nessuna libreria arriva dalla rete.** Tutte stanno in
|
||||
[`src/frontend/flask_app/static/vendor/`](src/frontend/flask_app/static/vendor/VERSIONS.md)
|
||||
con versione nel nome e impronta SHA-256 verificata da un test: l'installazione a
|
||||
Tràfilo è su rete di produzione isolata, dove una pagina che aspetta un CDN è una
|
||||
pagina bianca. Una Content-Security-Policy a sola origine locale (`app.py`) fa sì che
|
||||
un tag verso l'esterno aggiunto in futuro venga rifiutato alla scrivania, non in
|
||||
reparto.
|
||||
|
||||
### Tooling
|
||||
|
||||
| Componente | Ruolo |
|
||||
@@ -211,7 +247,8 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
||||
|---|---|
|
||||
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
|
||||
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
|
||||
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
|
||||
| **Supervisor** (capoturno) | Autorizza ciò che l'operatore non può decidere da solo: una quota fuori tolleranza che deve restare, il fermo linea, la fine produzione. L'autorizzazione resta scritta sulla misura (`supervisor_id`, `authorised_at`) e finisce nel file di statistica |
|
||||
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart. **L'operatore non raggiunge la statistica da nessun percorso** |
|
||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
|
||||
|
||||
---
|
||||
@@ -229,6 +266,7 @@ TieMeasureFlow/
|
||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||
├── nginx/ # Config Nginx (dev)
|
||||
├── uploads/ # Volume Docker file caricati
|
||||
├── scripts/ # Script del progetto (seed ricette di collaudo)
|
||||
├── docs/ # Documentazione (vedi indice docs/README.md)
|
||||
└── src/
|
||||
├── backend/ # FastAPI Backend
|
||||
@@ -245,11 +283,14 @@ TieMeasureFlow/
|
||||
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
|
||||
│ │ │ # AccessLog, SystemSetting,
|
||||
│ │ │ # RecipeVersionAudit, Station,
|
||||
│ │ │ # StationRecipeAssignment
|
||||
│ │ │ # StationRecipeAssignment,
|
||||
│ │ │ # ProductionRun, ProductionEvent
|
||||
│ │ └── api/ # Pydantic v2 schemas request/response
|
||||
│ ├── services/ # recipe_service, measurement_service,
|
||||
│ │ # spc_service, report_service,
|
||||
│ │ # auth_service, station_service
|
||||
│ │ # auth_service, station_service,
|
||||
│ │ # production_service,
|
||||
│ │ # production_export_service
|
||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||
│ ├── templates/ # Pagina setup (Jinja2)
|
||||
│ └── tests/ # pytest + httpx + aiosqlite
|
||||
@@ -262,9 +303,13 @@ TieMeasureFlow/
|
||||
├── services/ # APIClient (proxy verso FastAPI con XFF)
|
||||
├── templates/ # Jinja2 + Alpine.js
|
||||
├── static/
|
||||
│ ├── css/ # TailwindCSS compilato
|
||||
│ └── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ # spc-charts, annotation-editor/viewer
|
||||
│ ├── css/ # TailwindCSS compilato + themes.css
|
||||
│ │ # (cornice pagina, temi, scrollbar)
|
||||
│ ├── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ │ # spc-charts, annotation-editor/viewer,
|
||||
│ │ # production-clock, rich-text
|
||||
│ └── vendor/ # Alpine, Plotly, PDF.js (+worker), Fabric,
|
||||
│ # font woff2 — vedi VERSIONS.md
|
||||
├── translations/ # Flask-Babel .po/.mo IT/EN
|
||||
└── tests/
|
||||
```
|
||||
@@ -341,7 +386,98 @@ uv run pytest --cov src/backend
|
||||
uv run pytest src/frontend/flask_app/tests/
|
||||
```
|
||||
|
||||
Stato corrente: **183 pass, 0 fail** (vedi `docs/architecture/STATO_PROGETTO.md`).
|
||||
Il modulo di visione (`src/vision/`) è un albero a parte, con un vincolo di
|
||||
versione diverso dal resto del monorepo: le librerie di VisionSuite da cui
|
||||
dipende richiedono Python 3.13 o superiore, mentre backend e frontend restano
|
||||
su Python 3.11. Per questo motivo l'esecuzione di `uv run pytest` con
|
||||
l'interprete di default mostra i test di `src/vision/tests/` come *skipped*,
|
||||
non come falliti: il pacchetto viene rilevato ma la sua esecuzione reale
|
||||
richiede un ambiente Python 3.13 dedicato, creato con il proprio extra `uv`.
|
||||
Per eseguirli davvero:
|
||||
|
||||
```bash
|
||||
# Prepara l'ambiente Python 3.13 con le dipendenze di visione
|
||||
uv sync --extra vision --extra dev --python 3.13
|
||||
|
||||
# Esegue i test del runner di visione in quell'ambiente
|
||||
uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests
|
||||
```
|
||||
|
||||
`src/vision_worker/tests/` eredita lo stesso vincolo su Python 3.13, perché
|
||||
il worker dipende da `src.vision.runner`, ma aggiunge dipendenze proprie -
|
||||
FastAPI, Uvicorn, Pillow, python-multipart - dichiarate in un extra
|
||||
separato, `vision-worker`, tenuto distinto da `vision` apposta: un
|
||||
consumatore che incorpora solo il runner (come un futuro agente di stazione,
|
||||
che non è un servizio web) non deve trascinarsi dietro un server che non gli
|
||||
serve. `vision-worker` include comunque `vision`, quindi un solo extra basta
|
||||
per avere un worker funzionante:
|
||||
|
||||
```bash
|
||||
uv run --python 3.13 --extra vision-worker --extra dev pytest src/vision_worker/tests
|
||||
```
|
||||
|
||||
Per eseguire entrambe le suite insieme, nominando esplicitamente entrambi
|
||||
gli extra:
|
||||
|
||||
```bash
|
||||
uv run --python 3.13 --extra vision --extra vision-worker --extra dev pytest src/vision/tests src/vision_worker/tests
|
||||
```
|
||||
|
||||
### Il worker di visione (`Dockerfile.vision`)
|
||||
|
||||
Il worker gira in un container separato dal server FastAPI principale, così
|
||||
che l'immagine dell'API resti leggera e un aggiornamento di VisionSuite non
|
||||
richieda di riavviare il traffico di produzione. Il container non pubblica
|
||||
porte verso l'esterno: lo raggiunge solo il server, all'indirizzo interno
|
||||
`http://vision:8100` sulla rete `tmflow-net`.
|
||||
|
||||
Ogni misura riporta il commit di VisionSuite che l'ha prodotta
|
||||
(`engine_version`), perché una stazione che misura con un motore diverso da
|
||||
quello atteso deve poter essere identificata. Il container, però, non ha
|
||||
accesso al repository Git del progetto principale — `vendor/visionsuite` è un
|
||||
submodule, e la copia `.git` che lo collega al repository ospitante non
|
||||
viene inclusa nel contesto di build — quindi la versione non può essere
|
||||
scoperta al volo dentro l'immagine. Va invece **stampata al momento del
|
||||
build**, leggendo il commit dalla macchina che esegue `docker compose build`,
|
||||
dove il repository Git è presente per intero:
|
||||
|
||||
```bash
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.dev.yml build vision
|
||||
```
|
||||
|
||||
La variabile viene passata come build argument (`ARG VISION_ENGINE_VERSION`
|
||||
in `Dockerfile.vision`) e fissata nell'immagine come variabile d'ambiente, in
|
||||
modo che il worker la trovi già pronta a ogni avvio senza doverla ricalcolare.
|
||||
Fuori da un container, in un checkout di sviluppo locale, la stessa funzione
|
||||
ricade su `git rev-parse` se la variabile non è impostata — comodo per
|
||||
lavorare sul runner senza Docker. Ma se un'immagine viene costruita senza
|
||||
passare `VISION_ENGINE_VERSION`, quel ripiego non ha nulla su cui appoggiarsi:
|
||||
il `.git` del submodule non arriva nel contesto di build, e il worker
|
||||
risponde con un errore esplicito su `/health` invece di indovinare una
|
||||
versione o restituire `"unknown"`. La build va quindi sempre lanciata con la
|
||||
variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando
|
||||
mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml`
|
||||
in produzione).
|
||||
|
||||
Stato corrente su `V3.0.0`, con `uv run pytest` su Python 3.11 e senza
|
||||
l'extra `vision` (un conteggio senza il suo ambiente non dice nulla, regola
|
||||
che vale anche qui): **390 pass, 1 fail, 4 skip** — 243 in `src/backend/tests/`,
|
||||
147 pass + 2 skip in `src/frontend/flask_app/tests/`, più 2 skip in
|
||||
`src/vision/tests/` e `src/vision_worker/tests/` per il vincolo su Python
|
||||
3.13 descritto sopra. Il fallimento è preesistente e indipendente da questo
|
||||
lavoro: `test_offline.py::test_no_first_party_script_calls_out`, dovuto a una
|
||||
copia locale di Fabric.js non tracciata da git (`static/js/fabric-debug.js`)
|
||||
che nei propri commenti cita pagine di documentazione esterne.
|
||||
|
||||
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
|
||||
proprietà che sopravvivono solo se qualcuno le controlla:
|
||||
|
||||
| File | Cosa impedisce |
|
||||
|---|---|
|
||||
| `test_offline.py` | Una libreria caricata dalla rete, un worker PDF.js lasciato sul CDN, una libreria sostituita senza aggiornare l'impronta |
|
||||
| `test_layout_shell.py` | Una vista che torna a dichiararsi la propria larghezza |
|
||||
| `test_template_js_syntax.py` | Una traduzione con l'apostrofo dentro una stringa JS a virgolette singole, che spegne Alpine su tutta la pagina |
|
||||
|
||||
---
|
||||
|
||||
@@ -359,6 +495,7 @@ Copia `.env.example` in `.env` e configura:
|
||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||
| `VISION_WORKER_URL` | Indirizzo interno del worker di visione (default: `http://vision:8100`, mai esposto fuori da `tmflow-net`) |
|
||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||
@@ -377,6 +514,7 @@ Indice completo: [`docs/README.md`](docs/README.md).
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [`TieMeasureFlow_modifiche_2026-07-28.md`](TieMeasureFlow_modifiche_2026-07-28.md) | I quindici punti del 28/07: cosa deve cambiare, dove intervenire, decisioni in attesa del cliente (D-1…D-9) |
|
||||
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
||||
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
||||
|
||||
@@ -388,6 +526,8 @@ Indice completo: [`docs/README.md`](docs/README.md).
|
||||
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
|
||||
| [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava e la regola che lo tiene fermo |
|
||||
| [`src/frontend/flask_app/static/vendor/VERSIONS.md`](src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte, come aggiornarle |
|
||||
|
||||
### Piani dettagliati
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ services:
|
||||
environment:
|
||||
DB_HOST: mysql
|
||||
UPLOAD_DIR: uploads
|
||||
VISION_WORKER_URL: http://vision:8100
|
||||
volumes:
|
||||
- upload_data:/app/uploads
|
||||
depends_on:
|
||||
@@ -41,6 +42,23 @@ services:
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
vision:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.vision
|
||||
args:
|
||||
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||
container_name: tmflow-vision
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
client:
|
||||
build:
|
||||
context: .
|
||||
|
||||
@@ -33,6 +33,7 @@ services:
|
||||
environment:
|
||||
DB_HOST: mysql
|
||||
UPLOAD_DIR: uploads
|
||||
VISION_WORKER_URL: http://vision:8100
|
||||
volumes:
|
||||
- upload_data:/app/uploads
|
||||
depends_on:
|
||||
@@ -50,6 +51,23 @@ services:
|
||||
- tmflow-net
|
||||
- traefik-net
|
||||
|
||||
vision:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.vision
|
||||
args:
|
||||
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||
container_name: tmflow-vision
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
client:
|
||||
build:
|
||||
context: .
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Scaletta di collaudo — V3.0.0
|
||||
|
||||
Tredici dei quindici punti del 28/07 sono in esercizio e **nessuno li ha ancora
|
||||
percorsi**. Questa è la scaletta per farlo: ordinata in modo che ogni prova prepari
|
||||
la successiva, e scritta perché si possa spuntare riga per riga.
|
||||
|
||||
Per ogni riga: cosa fare, e **come si vede che è giusto**. Se qualcosa non torna,
|
||||
annotare il testo esatto dell'eventuale errore in console — non «dava errore».
|
||||
|
||||
Totale stimato: **circa 90 minuti**, di cui 30 sul solo punto 3 (il timer, che si
|
||||
misura aspettando).
|
||||
|
||||
---
|
||||
|
||||
## 0 · Preparazione (5 min)
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 0.1 | Aprire il sito e fare **ricarica forzata** (`Ctrl+Shift+R`) | CSS e JS sono in cache per 7 giorni: senza questo si collauda la versione di ieri |
|
||||
| 0.2 | Aprire la **console del browser** (`F12`) e lasciarla aperta per tutto il collaudo | Molte prove si leggono lì, non a schermo |
|
||||
| 0.3 | Verificare che esistano le ricette `COLLAUDO-A` e `COLLAUDO-B` | Sono in `/measure/select`. Se una sessione precedente le ha sporcate: `docker compose exec server uv run python /tmp/seed_collaudo.py --replace` (cancella anche le misure fatte su di esse) |
|
||||
|
||||
**Credenziali del capoturno:** `capoturno` / `Collaudo2026!` — da cambiare prima di
|
||||
qualunque uso reale.
|
||||
|
||||
**Le ricette:**
|
||||
|
||||
- **COLLAUDO-A** — lotto obbligatorio, digitazione ammessa, intervallo **2 minuti**.
|
||||
Cinque task: nota → misura (2 quote) → nota → misura (1 quota) → nota.
|
||||
- **COLLAUDO-B** — lotto **e** seriale obbligatori, **solo calibro**, una quota.
|
||||
|
||||
**Le quote e i valori da usare** (tutte con nominale al centro):
|
||||
|
||||
| Quota | Task | Nominale | Conforme | Attenzione | Fuori tolleranza |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 · Diametro esterno | A, misura 1 | 10.00 | `10.00` | `10.30` | `12.00` |
|
||||
| 2 · Spessore parete | A, misura 1 | 25.00 | `25.00` | `25.30` | `27.00` |
|
||||
| 3 · Lunghezza totale | A, misura 2 | 100.00 | `100.00` | `100.70` | `105.00` |
|
||||
|
||||
---
|
||||
|
||||
## 1 · Layout — punto 14 (10 min) · **da monitor da scrivania**
|
||||
|
||||
Va fatto **prima** e **non dal tablet**: in verticale su un tablet quasi tutte le
|
||||
larghezze coincidono e il difetto non si vede. Serve uno schermo largo.
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 1.1 | Percorrere Ricette → apri una ricetta → Task → Disegno → Anteprima, e tornare indietro | Il blocco di contenuto **non cambia larghezza** fra una schermata e l'altra |
|
||||
| 1.2 | Su ognuna, guardare il bordo sinistro del contenuto e quello della barra in alto | Sono **allineati**: il contenuto sta dentro la stessa cornice della navbar |
|
||||
| 1.3 | Passare da una pagina lunga (che scorre) a una corta (che non scorre) | Niente **salta di lato** di qualche pixel: lo spazio della barra di scorrimento è riservato sempre |
|
||||
| 1.4 | Entrare in un task di misura e uscirne | Stesso controllo: nessuno spostamento orizzontale |
|
||||
| 1.5 | Admin → Utenti, Stazioni, Impostazioni; poi il proprio Profilo | Utenti e Stazioni sono larghe, Impostazioni e Profilo strette — ma con **gli stessi margini esterni** |
|
||||
| 1.6 | **Dal tablet**, entrare in un task di misura | Il piede con «Fine ciclo misura» e il tastierino è visibile **senza scorrere**, anche quando la barra dell'indirizzo compare e sparisce |
|
||||
|
||||
---
|
||||
|
||||
## 2 · Niente arriva dalla rete — punto 12 (15 min)
|
||||
|
||||
Da fare **adesso**, perché la Content-Security-Policy è appena entrata in vigore: se
|
||||
una libreria avesse bisogno di un permesso non concesso, il sintomo compare ora.
|
||||
|
||||
> **Attenzione al metodo.** Il sistema è oggi su una VPS pubblica: staccare la rete
|
||||
> significherebbe perdere anche il server, quindi non prova niente. La prova
|
||||
> equivalente e più diretta è guardare **dove vanno le richieste**. Il giro a rete
|
||||
> davvero staccata si fa in reparto, sull'installazione on-premise.
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 2.1 | Console → scheda **Rete**, spuntare «Disabilita cache», ricaricare | Ordinando per dominio compare **un solo dominio**: `tieflow.tielogic.xyz`. Nessun `cdn`, `jsdelivr`, `cloudflare`, `googleapis`, `plot.ly` |
|
||||
| 2.2 | Ripetere su: login, selezione ricetta, esecuzione task, editor annotazioni (`/maker`), dashboard statistiche | Sempre un dominio solo. La dashboard è quella che carica Plotly, l'editor quella che carica Fabric |
|
||||
| 2.3 | Aprire un task che ha un **disegno PDF** allegato | Il disegno si vede. È la prova del *worker* di PDF.js: se cercasse la rete, tutto sembrerebbe a posto fino a questo momento |
|
||||
| 2.4 | Con la console aperta su ogni pagina del giro | **Zero** messaggi che iniziano con `Refused to load…` o `…violates the following Content Security Policy directive`. Se ce n'è uno, copiarlo per intero: dice quale permesso manca |
|
||||
| 2.5 | Generare un **report PDF** dalle statistiche | Il PDF esce. È generato sul server, non nel browser, ma vale la pena vederlo funzionare nel giro completo |
|
||||
|
||||
---
|
||||
|
||||
## 3 · Le regole della ricetta — punti 8 e 9 (10 min) · **COLLAUDO-B**
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 3.1 | `/measure/select`, **senza** compilare lotto e seriale | Sulla scheda di COLLAUDO-B il pulsante è spento e dice **«Compila prima: lotto, seriale»** |
|
||||
| 3.2 | Compilare **solo** il lotto | Il pulsante resta spento e ora chiede solo il **seriale** |
|
||||
| 3.3 | Provare ad andare a mano su `/measure/start/<id>` di COLLAUDO-B senza seriale | Rimanda alla selezione con il messaggio «Compila prima:». La regola non si aggira dall'indirizzo |
|
||||
| 3.4 | Compilare entrambi e avviare | Entra nel task |
|
||||
| 3.5 | Guardare il tastierino | I tasti numerici **non ci sono affatto** — non sono grigi, non ci sono. Restano «cancella» e «conferma» |
|
||||
| 3.6 | Con un **calibro USB** collegato, prendere una lettura | Il valore entra e si salva |
|
||||
| 3.7 | *(facoltativo, per chi vuole la prova dura)* Provare a salvare un valore digitato aggirando lo schermo | Il server risponde **422**: la regola vive lì, non nella pagina |
|
||||
|
||||
---
|
||||
|
||||
## 4 · Il primo ciclo e l'avvio produzione — punti 10 e 11 (10 min) · **COLLAUDO-A**
|
||||
|
||||
> Da qui in avanti si lavora su COLLAUDO-A. **Il timer non parte subito**: prima si
|
||||
> fa un ciclo completo, poi compare «Avvio Produzione». È voluto — il primo pezzo è
|
||||
> l'attrezzaggio, non produzione.
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 4.1 | Selezione ricetta → COLLAUDO-A → lotto `LOT-TEST-01` → **AVVIA IN SEQUENZA** | Si apre **il primo task** (la nota «Preparazione del pezzo»), **non** l'elenco dei task |
|
||||
| 4.2 | Leggere la nota | «a sinistra» è in **grassetto**, le righe vanno a capo e la riga vuota si vede (punto 11) |
|
||||
| 4.3 | Guardare i due pulsanti in alto a destra | Sono **«Lista task»** e **«Completato»**. Su una nota «Completato» è attivo |
|
||||
| 4.4 | Premere «Completato» | Passa al task 2, «Misura 1 — diametro e spessore» |
|
||||
| 4.5 | Guardare il pulsante in basso a destra | **«Mancano 2 quote»**, grigio e non cliccabile |
|
||||
| 4.6 | Misurare la quota 1 con `10.00` e confermare | Verde (conforme). Il pulsante in basso ora dice **«Manca 1 quota»** |
|
||||
| 4.7 | **Prova chiave del punto 10**: premere «Lista task» adesso, a metà | Nel'elenco il task «Misura 1» porta **«Incompiuto 1/2»** con il bordo ambra. Gli altri non dicono niente |
|
||||
| 4.8 | Premere **«Visualizza singolo TASK»** su quel task | Ci si torna dentro, con la quota già presa |
|
||||
| 4.9 | Misurare la quota 2 con `25.00` | Il pulsante in basso diventa blu: **«Fine ciclo misura»** |
|
||||
| 4.10 | Premerlo | Compare il verde **«Task successivo»** (non un secondo «Completato») |
|
||||
| 4.11 | Guardare sotto il piede della pagina | Compare il pulsante verde **«Avvio Produzione»** |
|
||||
| 4.12 | Premere «Avvio Produzione» | Parte il conto alla rovescia da **2 minuti**. Da qui in avanti compaiono «Fermo linea» e «Fine produzione» |
|
||||
|
||||
---
|
||||
|
||||
## 5 · Il fuori tolleranza — punto 5 (15 min)
|
||||
|
||||
La parte più importante del collaudo: fino a ieri questo gate era una modale che si
|
||||
poteva chiudere.
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 5.1 | Tornare su «Misura 1». Cliccare la **quota 1** in alto per rimisurarla, e inserire `12.00` | Si salva ed è **rossa**. La misura sbagliata resta a registro: è un dato, non un errore da nascondere |
|
||||
| 5.2 | Provare a passare alla **quota 2** e salvare un valore | **Rifiutato.** Compare l'avviso che serve l'autorizzazione |
|
||||
| 5.3 | Guardare «Completato» in alto | È **spento**, e passandoci sopra dice «Serve l'autorizzazione del capoturno» |
|
||||
| 5.4 | **Ricaricare la pagina** (`F5`) | Il blocco **è ancora lì**. Ricaricare non è una via d'uscita |
|
||||
| 5.5 | **La via d'uscita senza capoturno**: cliccare di nuovo la quota 1 e rimisurarla con `10.00` | Il blocco **si scioglie**. È voluto: il calibro scivola, il pezzo si riposiziona, e chiamare il capoturno per questo sarebbe assurdo |
|
||||
| 5.6 | Rifarlo: quota 1 con `12.00`, e stavolta **autorizzare** con `capoturno` / `Collaudo2026!` | Il blocco si scioglie e la misura resta segnata come autorizzata |
|
||||
| 5.7 | Provare ad autorizzare con una password sbagliata | Rifiutato, e il blocco **resta** |
|
||||
| 5.8 | Provare ad autorizzare con un utente **senza** ruolo Supervisor (es. l'operatore stesso) | Rifiutato: non basta essere collegati, serve il ruolo |
|
||||
| 5.9 | Rimettere `10.00` sulla quota 1 e `25.00` sulla 2, chiudere il ciclo | Si prosegue normalmente |
|
||||
|
||||
---
|
||||
|
||||
## 6 · Il ciclo di misura — punto 3 (30 min, quasi tutti di attesa)
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 6.1 | Con il conto alla rovescia in corso, **cambiare task** | Il tempo residuo **prosegue**, non riparte da 2 minuti |
|
||||
| 6.2 | **Ricaricare la pagina** (`F5`) | Idem: il tempo è lo stesso. Vive sul server, non nella pagina |
|
||||
| 6.3 | Fermarsi su una **nota** e lasciare scadere l'intervallo | A ~5 secondi dalla scadenza compare l'avviso e **suona il cicalino** |
|
||||
| 6.4 | Non toccare niente | Alla scadenza si viene portati **al primo task di misura** della ricetta, non a quello dove si era |
|
||||
| 6.5 | Fare un ciclo completo: misura 1 (2 quote) → nota → **misura 2** (quota 3, `100.00`) → «Fine ciclo misura» | Chiudendo il ciclo **sull'ultimo task di misura** l'intervallo **riparte da 2 minuti** |
|
||||
| 6.6 | Ripetere ma chiudendo il ciclo sulla **prima** misura | L'intervallo **non** riparte: il ciclo si chiude solo sull'ultimo task di misura |
|
||||
| 6.7 | **La prova più delicata**: stare **dentro** un task di misura, con una quota già presa e una da prendere, e lasciare scadere l'intervallo | **Non** si viene spostati. Il conteggio diventa **rosso** e prosegue **oltre lo zero**, mostrando il ritardo. Chi sta misurando va lasciato finire |
|
||||
| 6.8 | Usare **«Rimisura»** dopo aver completato le quote | Registra una seconda lettura dello stesso pezzo **senza** far ripartire l'intervallo. Girare il pezzo e rimisurare non è un pezzo nuovo |
|
||||
|
||||
---
|
||||
|
||||
## 7 · Fermo linea e fine produzione — punto 6 (10 min)
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 7.1 | Dentro un task di misura, premere **«Fermo linea»** → credenziali capoturno | Il conto alla rovescia **si ferma** |
|
||||
| 7.2 | Ricaricare la pagina | È ancora fermo: lo stato è sul server |
|
||||
| 7.3 | Premere **«Ripresa»** → credenziali | Riparte |
|
||||
| 7.4 | Premere **«Fine produzione»** → credenziali | La produzione si chiude |
|
||||
| 7.5 | Sul server: `docker compose exec server ls uploads/statistics/` | C'è un file `production_<id>_<data>.csv` |
|
||||
| 7.6 | Aprire il file | Contiene **tutte** le misure della produzione, compresi i `12.00`. Sulla riga autorizzata le colonne `authorised_by` e `authorised_at` sono **valorizzate**: il file mostra il guasto **e** la decisione |
|
||||
| 7.7 | Aprire il file in Excel | Le accentate si leggono, il separatore è quello configurato |
|
||||
|
||||
---
|
||||
|
||||
## 8 · La statistica resta separata — punto 15 (5 min)
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 8.1 | Collegarsi con un utente che ha **solo** il ruolo MeasurementTec | In barra **non c'è** la voce Statistiche |
|
||||
| 8.2 | Percorrere tutto il flusso operatore guardando ogni schermata | Nessun collegamento porta alla statistica, in nessun punto |
|
||||
| 8.3 | Andare a mano su `/statistics/dashboard` | **403**: il ruolo non basta |
|
||||
| 8.4 | Verificare che le misure ci siano comunque | Con un utente Metrologist, le misure appena fatte compaiono in dashboard. Si registra sempre, si consulta a parte |
|
||||
|
||||
---
|
||||
|
||||
## Cosa annotare
|
||||
|
||||
Per ogni riga che non torna:
|
||||
|
||||
1. **Numero della riga** di questa scaletta.
|
||||
2. **Cosa è successo** invece di quello che c'è scritto.
|
||||
3. Se c'è un errore in console, il **testo esatto**, per intero.
|
||||
4. Se è un problema di aspetto, uno **screenshot** e su **quale schermo** (tablet o
|
||||
monitor, e in che orientamento) — per il punto 14 è l'informazione che manca.
|
||||
|
||||
## Cosa questa scaletta non copre
|
||||
|
||||
- Il **punto 4** (numero di tentativi prima del capoturno) non è implementato: è
|
||||
fermo sulle risposte del cliente (D-6, D-7, D-9).
|
||||
- Il **cicalino** è il suono del browser. Se serva una colonnina luminosa è D-5.
|
||||
- Il giro a **rete davvero staccata** si fa in reparto, sull'installazione
|
||||
on-premise: qui si prova l'equivalente (nessuna richiesta esce dal dominio).
|
||||
- Il **carico** con venti tablet insieme non è mai stato misurato.
|
||||
+10
-3
@@ -6,8 +6,11 @@ Indice della documentazione del progetto.
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V2.0.0). Snapshot del sistema, componenti e capacità. |
|
||||
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta da fare. Fasi 2-7 della migrazione rev04 verso V1.1.0/M1 demo cliente. |
|
||||
| [`../TieMeasureFlow_modifiche_2026-07-28.md`](../TieMeasureFlow_modifiche_2026-07-28.md) | **Il piano di lavoro corrente**: i quindici punti del sopralluogo del 28/07 e le decisioni in attesa del cliente (D-1…D-9). |
|
||||
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V3.0.0), punto per punto, e cosa non è ancora stato provato sul campo. |
|
||||
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta, in ordine: collaudo, punto 4, innesto GAIA, installazione a Tràfilo. Include la mappatura con le Fasi rev04 di aprile. |
|
||||
| [`architecture/LAYOUT.md`](architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava fra le viste e la regola che lo tiene fermo. |
|
||||
| [`../src/frontend/flask_app/static/vendor/VERSIONS.md`](../src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte SHA-256, come aggiornarle. |
|
||||
|
||||
## Riferimenti operativi
|
||||
|
||||
@@ -17,8 +20,12 @@ Indice della documentazione del progetto.
|
||||
| [`DEPLOYMENT.md`](DEPLOYMENT.md) | Guida deploy su VPS (Hostinger/Tielogic con Traefik + Let's Encrypt). |
|
||||
| [`USER_GUIDE.md`](USER_GUIDE.md) | Manuale utente (operatore, maker, metrologist, admin). |
|
||||
| [`I18N_SETUP.md`](I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js). |
|
||||
| [`COLLAUDO.md`](COLLAUDO.md) | **Scaletta di collaudo V3.0.0**: cosa provare, in che ordine, con quali valori, e come si vede che è giusto. |
|
||||
|
||||
## Piani dettagliati TDD (rev04)
|
||||
## Piani dettagliati TDD (rev04) — storici
|
||||
|
||||
> Il piano rev04 di aprile è **superato** dal documento del 28/07. La mappatura fra
|
||||
> le sue sette Fasi e i quindici punti correnti è in fondo a `architecture/ROADMAP.md`.
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Cornice delle pagine
|
||||
|
||||
Nasce dal punto 14 del documento del 28/07: *«le dimensioni delle viste cambiano a
|
||||
seconda del menu; passando da una schermata all'altra la finestra non mantiene
|
||||
proporzioni stabili»*. La segnalazione era di un operatore e non era ancora stata
|
||||
circoscritta sul codice. Questo è l'esito della circoscrizione e la regola che ne
|
||||
è uscita.
|
||||
|
||||
## Cosa succedeva
|
||||
|
||||
Tre meccanismi distinti, tutti verificabili sul codice senza avere il tablet in mano.
|
||||
|
||||
**1. Ogni vista si dichiarava la propria larghezza.** Sette valori diversi su
|
||||
diciassette template, e nessuno coincideva con quello della navbar, che sta a
|
||||
`max-w-7xl` su tutte le pagine. Il contenuto risultava quindi disallineato dalla
|
||||
barra sopra di sé, e il disallineamento cambiava da pagina a pagina.
|
||||
|
||||
| vista | prima | dopo |
|
||||
|---|---|---|
|
||||
| `measure/select_recipe.html` | `max-w-7xl` `py-8` | `tmf-page` |
|
||||
| `measure/task_list.html` | `max-w-5xl` `py-8` | `tmf-page` |
|
||||
| `measure/task_complete.html` | `max-w-7xl` `py-6` | `tmf-page` |
|
||||
| `maker/recipe_list.html` | `max-w-6xl` `py-8` | `tmf-page` |
|
||||
| `maker/task_editor.html` | `max-w-5xl` `py-8` | `tmf-page` |
|
||||
| `maker/task_drawing.html` | `max-w-5xl` `py-8` | `tmf-page` |
|
||||
| `maker/recipe_preview.html` | `max-w-5xl` `py-8` | `tmf-page` |
|
||||
| `admin/stations.html` | `max-w-7xl` `py-6` | `tmf-page` |
|
||||
| `admin/users.html` | `max-w-7xl` `py-6` | `tmf-page` |
|
||||
| `statistics/dashboard.html` | `max-w-7xl` `py-6` | `tmf-page` |
|
||||
| `admin/settings.html` | `max-w-3xl` `py-6` | `tmf-page tmf-page-narrow` |
|
||||
| `auth/profile.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
|
||||
| `maker/recipe_editor.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
|
||||
| `maker/version_history.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
|
||||
| `errors/station_not_configured.html` | `max-w-2xl` `py-8` | `tmf-page tmf-page-narrow` |
|
||||
|
||||
Il percorso dell'operatore — quello da cui è arrivata la segnalazione — faceva
|
||||
`1280px → 1024px → tutto schermo → 1280px` in quattro passaggi.
|
||||
|
||||
**2. Il padding verticale non era lo stesso.** `py-8` e `py-6` mescolati fra viste
|
||||
consecutive: la prima card si trovava a un'altezza diversa a ogni cambio di
|
||||
schermata.
|
||||
|
||||
**3. La barra di scorrimento appariva e spariva.** È una barra classica da 8px
|
||||
(`themes.css`, `::-webkit-scrollbar`), quindi occupa spazio nel layout. Una pagina
|
||||
lunga la mostrava, una corta no, e la schermata di misura la toglie sempre
|
||||
(`body { overflow: hidden }`): a ogni navigazione tutto il contenuto centrato —
|
||||
navbar compresa — si spostava di 8px in orizzontale.
|
||||
|
||||
## La regola
|
||||
|
||||
Due classi in `static/css/themes.css`, nessuna larghezza nei template.
|
||||
|
||||
- **`.tmf-page`** — la cornice: `max-width: 80rem`, padding `1rem / 1.5rem / 2rem`
|
||||
ai tre breakpoint, `padding-block: 1.5rem`. Sono esattamente i valori della
|
||||
navbar (`max-w-7xl px-4 sm:px-6 lg:px-8`), così il contenuto ci si allinea.
|
||||
- **`.tmf-page-narrow`** — si aggiunge alla prima e stringe a `56rem`. È per i
|
||||
moduli: un campo di testo largo 1280px non si compila meglio.
|
||||
- **`html { scrollbar-gutter: stable }`** — lo spazio della barra è riservato
|
||||
sempre, anche dove la pagina non scorre.
|
||||
- **`body.h-screen { height: 100dvh }`** — la schermata di misura è alta quanto la
|
||||
finestra vera. `100vh` su Android e iOS è l'altezza che la pagina avrebbe con la
|
||||
barra dell'indirizzo nascosta: su un tablet il piede della schermata — dove
|
||||
stanno «Fine ciclo misura» e il tastierino — finiva sotto il bordo, e ricompariva
|
||||
quando la barra si ritraeva.
|
||||
|
||||
Due larghezze in tutto il prodotto al posto di sette, e il criterio è il tipo di
|
||||
pagina: liste, tabelle e tele di disegno stanno larghe, i moduli stanno stretti.
|
||||
|
||||
## Le due eccezioni
|
||||
|
||||
Hanno geometria propria per un motivo, e sono elencate in
|
||||
`tests/test_layout_shell.py` perché non sembrino dimenticate.
|
||||
|
||||
- **`auth/login.html`** — schermata piena senza navbar: non c'è niente a cui
|
||||
allinearsi.
|
||||
- **`measure/task_execute.html`** — la schermata di misura è un pannello a tutta
|
||||
altezza che non deve scorrere mentre un operatore sta misurando
|
||||
(`h-screen overflow-hidden`, footer nascosto). È l'unica vista in cui il cambio
|
||||
di forma è voluto, ed è anche l'unica in cui l'operatore si ferma a lavorare:
|
||||
entrarci e uscirne resta un salto, ma ora è l'unico.
|
||||
|
||||
## Cosa resta da verificare sul campo
|
||||
|
||||
La segnalazione non è mai stata riprodotta su un dispositivo: quanto sopra viene
|
||||
dalla lettura del codice, non da una prova. Quello che è dimostrato è che il
|
||||
layout *poteva* muoversi per quattro motivi distinti e che ora non può più per
|
||||
nessuno dei quattro.
|
||||
|
||||
Se l'operatore vedesse ancora spostamenti, il sospetto successivo è la **tastiera
|
||||
virtuale**: quando compare, il browser ridimensiona la finestra, e con `100dvh` la
|
||||
schermata di misura si ridimensiona con lei invece di scorrere sotto. È il
|
||||
comportamento giusto per il tastierino a schermo, ma va guardato con un calibro
|
||||
USB collegato, dove la tastiera di sistema non dovrebbe comparire affatto.
|
||||
|
||||
Da chiedere all'operatore che ha aperto la segnalazione: **su quale schermo** l'ha
|
||||
vista. Su un tablet in verticale la maggior parte delle larghezze qui sopra
|
||||
collassa allo stesso valore e il difetto non si vede; su un monitor da scrivania
|
||||
si vede tutto.
|
||||
+126
-75
@@ -1,95 +1,146 @@
|
||||
# Roadmap TieMeasureFlow — V2.0.0 → V1.1.0 (rev04 / M1 demo cliente)
|
||||
# Roadmap TieMeasureFlow — V3.0.0 → installazione a Tràfilo
|
||||
|
||||
> Aggiornare ad ogni Fase chiusa.
|
||||
> Aggiornata al 2026-07-28. Aggiornare a ogni punto chiuso.
|
||||
|
||||
## Riferimenti
|
||||
## Dove siamo
|
||||
|
||||
- Master plan dettagliato: [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md)
|
||||
- Spec sorgente: [`../specs/2026-04-16-schema-sviluppo-rev04.docx`](../specs/2026-04-16-schema-sviluppo-rev04.docx)
|
||||
- Stato corrente: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
|
||||
La roadmap rev04 di aprile (Fasi 1-7, milestone M1/M2) è **superata dai fatti**: il
|
||||
sopralluogo del 28/07 ha prodotto un elenco di quindici punti concreti che è oggi il
|
||||
piano di lavoro. Buona parte delle vecchie Fasi 2 e 4 è dentro quei punti ed è già
|
||||
fatta; il resto è confluito o decaduto. La mappatura è in fondo, per non perdere il
|
||||
filo con i documenti di aprile.
|
||||
|
||||
## Strategia: due milestone
|
||||
- Piano corrente: [`../../TieMeasureFlow_modifiche_2026-07-28.md`](../../TieMeasureFlow_modifiche_2026-07-28.md)
|
||||
- Stato di dettaglio: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
|
||||
- Piano rev04 (storico): [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md)
|
||||
|
||||
| Milestone | Scope | Obiettivo |
|
||||
**Scadenza che comanda tutto: l'installazione on-premise a Tràfilo è prevista per
|
||||
settembre 2026.**
|
||||
|
||||
## I quindici punti
|
||||
|
||||
| # | Punto | Stato |
|
||||
|---|---|---|
|
||||
| **M1 — Demo cliente** | Fasi 1-5 + deploy "demo" | Sistema testabile end-to-end col cliente per raccogliere feedback |
|
||||
| **M2 — Produzione** | Fasi 6-7 + correzioni post-feedback + GAIA live | Rollout su tablet/PC reali |
|
||||
| 1 | Memoria della produzione in corso | ✅ fatto |
|
||||
| 2 | Tipo di task esplicito | ✅ fatto |
|
||||
| 3 | Loop di misura e ripetizione | ✅ fatto |
|
||||
| 4 | Limite di tentativi prima del capoturno | ⛔ **fermo** su D-6, D-7, D-9 |
|
||||
| 5 | Avanzamento solo se in tolleranza | ✅ fatto |
|
||||
| 6 | Fermo linea e Fine produzione con effetto | ✅ fatto (innesto GAIA fermo su D-1, D-2) |
|
||||
| 7 | Gestione delle stazioni | ✅ fatto |
|
||||
| 8 | Tracciabilità obbligatoria | ✅ fatto |
|
||||
| 9 | Blocco dell'inserimento manuale | ✅ fatto |
|
||||
| 10 | Interfaccia operatore: sequenza e conferme | ✅ fatto |
|
||||
| 11 | Formattazione delle descrizioni | ✅ fatto |
|
||||
| 12 | Funzionamento senza internet | ✅ fatto |
|
||||
| 13 | Generazione task dalla scheda tecnica con l'AI | — fuori offerta (quotazione su D-8) |
|
||||
| 14 | Stabilità del layout | ✅ fatto |
|
||||
| 15 | Statistica separata dalla registrazione | ✅ fatto |
|
||||
|
||||
## Stato Fasi (M1)
|
||||
## Cosa resta, in ordine
|
||||
|
||||
| Fase | Scope | Stato | Branch / Commit |
|
||||
### 1. Collaudo sul campo — *da fare adesso, non serve nessuna risposta*
|
||||
|
||||
È il lavoro più urgente rimasto, ed è l'unico che non dipende da nessuno. Tredici
|
||||
punti sono in esercizio e **nessuno li ha percorsi su un tablet**.
|
||||
|
||||
Le ricette `COLLAUDO-A` e `COLLAUDO-B` sono già sul sistema, con un utente
|
||||
`capoturno` (`scripts/seed_collaudo.py`). Da verificare, in quest'ordine:
|
||||
|
||||
| Cosa | Come si vede che è giusto |
|
||||
|---|---|
|
||||
| Ciclo di misura (3) | Il conto alla rovescia sopravvive al cambio task, prosegue in rosso oltre lo zero, riporta alla misura |
|
||||
| Fuori tolleranza (5) | Con `12.00` sulla prima quota non si passa alla seconda finché il capoturno non autorizza, o finché non si rimisura dentro |
|
||||
| Tracciabilità (8) | Su `COLLAUDO-B` l'avvio non parte finché lotto e seriale non ci sono |
|
||||
| Inserimento manuale (9) | Su `COLLAUDO-B` il tastierino numerico non c'è affatto |
|
||||
| Sequenza (10) | La ricetta si apre sul primo task; un task lasciato a metà appare «Incompiuto 1/2» nella lista |
|
||||
| Senza rete (12) | Staccare la rete e percorrere login → ricetta → task → annotazione → statistiche → report, console aperta |
|
||||
| Layout (14) | **Da un monitor da scrivania**, non solo dal tablet: è lì che il difetto si vedeva |
|
||||
|
||||
Il giro del punto 12 va fatto **ora**, perché la Content-Security-Policy è appena
|
||||
entrata in vigore: se una libreria avesse bisogno di un permesso non concesso, il
|
||||
sintomo compare adesso.
|
||||
|
||||
### 2. Punto 4 — appena arrivano le risposte
|
||||
|
||||
Numero di tentativi prima del capoturno. Serve sapere **D-7** (quanti, e se uguali
|
||||
per tutte le ricette), **D-6** (password, PIN o badge) e **D-9** (se cambiare i
|
||||
parametri crea una nuova versione). Il resto dell'impianto è pronto: il blocco per
|
||||
fuori tolleranza e l'autorizzazione del capoturno esistono già, manca il contatore.
|
||||
|
||||
Stima, a decisioni chiuse: **2-3 giorni**.
|
||||
|
||||
### 3. Innesto verso GAIA — fermo su D-1 e D-2
|
||||
|
||||
Avvio produzione, fermo linea e fine produzione hanno già lo stato e gli eventi; la
|
||||
fine produzione emette già il file di statistica. Manca solo il canale verso il
|
||||
gestionale, e non si può nemmeno disegnare finché non si sa **come** si parla con
|
||||
GAIA (D-1) e **da dove** (D-2).
|
||||
|
||||
Stima, a protocollo noto: **1-2 settimane**, molto dipendente dalla risposta.
|
||||
|
||||
### 4. Installazione a Tràfilo — settembre
|
||||
|
||||
| Cosa | Blocco |
|
||||
|---|---|
|
||||
| Macchina server, spazio disco | D-4 |
|
||||
| Immagini Docker portate in reparto, non repository da compilare sul posto | — |
|
||||
| Colonnina luminosa, se serve | D-5 |
|
||||
| Validazione rete e macchine con l'IT | D-4 |
|
||||
|
||||
Nota: **la costruzione delle immagini richiede rete** (npm, apt, uv); è l'esecuzione
|
||||
a non richiederla. In reparto va portata l'immagine già costruita.
|
||||
|
||||
## Decisioni in attesa del cliente
|
||||
|
||||
Da girare a Tràfilo tramite Menoncin. Le prime due e la D-4 hanno l'orizzonte di
|
||||
settembre; D-6 e D-7 bloccano lavoro che sappiamo già fare.
|
||||
|
||||
| ID | Decisione | Blocca | Chi risponde |
|
||||
|---|---|---|---|
|
||||
| **1** | Stazioni + identità per-tablet | ✅ **COMPLETATA** | `V2.0.0` (merge `ea8e468` da `feature/rev04-phase1-stations`) |
|
||||
| 2 | Ruolo Capoturno (Supervisor) + override token breve | ⏳ Da iniziare | — |
|
||||
| 3 | Editor ricetta a blocchi (preparation + measurement) | ⏳ Da iniziare | — |
|
||||
| 4 | Workflow operatore (retry/timer/autologout/avvio produzione) | ⏳ Da iniziare | — |
|
||||
| 5 (M1) | `ImportOnlyGaiaClient` + UI import dati cliente reali | ⏳ Da iniziare | — |
|
||||
| Deploy M1 | VPS demo (compose + Traefik + LE, no registry) | ⏳ Da iniziare | — |
|
||||
| **D-1** | Protocollo del gestionale GAIA: servizi web, database condiviso, file? | innesto GAIA (punto 6) | IT Tràfilo + fornitore GAIA |
|
||||
| **D-2** | Rete e credenziali per raggiungere GAIA | come sopra | IT Tràfilo |
|
||||
| ~~D-3~~ | ~~Una app per stazione o una sola?~~ **Decisa il 28/07**: dati sul server, app di stazione su ogni PC | — | chiusa; validazione in D-4 |
|
||||
| **D-4** | Server: quale macchina, quanto spazio disco (database **e** disegni) | installazione | IT Tràfilo |
|
||||
| **D-5** | Cicalino: basta il suono del browser o serve una colonnina luminosa? | punto 3 (completamento) | Tràfilo |
|
||||
| **D-6** | Autorizzazione capoturno: password come oggi, o PIN / badge? Venti volte al giorno la password è attrito | punti 4, 5, 6 | Tràfilo |
|
||||
| **D-7** | Quanti tentativi prima del capoturno, e uguali per tutte le ricette? | punto 4 | Tràfilo |
|
||||
| **D-8** | Schede tecniche: quante, formato standard, conversione una-tantum o funzione permanente? | punto 13 e la sua quotazione | Tràfilo |
|
||||
| **D-9** | Cambiare i parametri di una ricetta (timer, tentativi) crea una nuova versione? | punti 3, 4 | noi, con conferma cliente |
|
||||
|
||||
## Stato Fasi (M2)
|
||||
Una decisione già presa che va **confermata**: la migrazione 009 ha impostato
|
||||
`allow_manual_input = 1` su tutte le ricette esistenti, per conservare il
|
||||
comportamento in essere invece di irrigidire di colpo ricette già in uso. Va deciso
|
||||
ricetta per ricetta quali devono passare a solo calibro.
|
||||
|
||||
| Fase | Scope | Stato |
|
||||
|---|---|---|
|
||||
| 5 (M2) | GAIA reale (protocollo TBD, polling, comandi produzione) | ⏳ Bloccata da decisioni cliente (D-0.1, D-0.2) |
|
||||
| 6 | Deploy B industriale (registry privato + Watchtower + STATION_ID per-tablet + CI release) | ⏳ Pianificata |
|
||||
| 7 | Hardening, security review, E2E sito pilota, docs aggiornati, i18n delta | ⏳ Pianificata |
|
||||
|
||||
## Decisioni aperte (bloccanti per M2 / future fasi)
|
||||
|
||||
Da: master plan §0 "Precondizioni e Decisioni Aperte". Da risolvere col cliente prima della Fase 5/6.
|
||||
|
||||
| ID | Decisione | Stato | Bloccante per |
|
||||
|---|---|---|---|
|
||||
| D-0.1 | Protocollo integrazione GAIA (REST / DB shared / OPC-UA / file) | **Aperta** | Fase 5 reale (M2) |
|
||||
| D-0.2 | Credenziali e rete GAIA (VPN / firewall / whitelist IP) | **Aperta** | Fase 5 reale (M2) |
|
||||
| D-0.3 | Target hardware "tablet" (Windows / Linux industriale / Android) | **Aperta** | Fase 6 (deploy B) |
|
||||
| D-0.4 | Cicalino/luce avviso (audio HTML5 / hardware USB / entrambi) | **Rimandata a M2** | Fase 4 finale |
|
||||
| D-0.5 | Parametri runtime modificabili vs versione immutabile | **Aperta** (raccomandato B: separare volatili) | Fase 3 |
|
||||
| D-0.6 | Auth capoturno durante override (modale / PIN / RFID) | **Aperta** | Fase 2 |
|
||||
| D-0.7 | Timeout auto-logout | **Risolta** | — |
|
||||
| D-0.8 | Naming ruolo capoturno | **Proposta:** `Supervisor` | Fase 2 |
|
||||
| D-0.9 | Tag versione immagine docker | **Proposta:** SemVer + `latest` | Fase 6 |
|
||||
| D-0.10 | Registry esposto su Internet o solo VPN | **Proposta:** solo VPN cliente | Fase 6 |
|
||||
|
||||
## Tech debt da chiudere
|
||||
## Tech debt
|
||||
|
||||
| Item | Priorità | Note |
|
||||
|---|---|---|
|
||||
| 3 test backend pre-esistenti rotti (`test_recipes`, `test_tasks`) | Media | Investigare prima di Fase 3 (toccano recipe + task router). |
|
||||
| 1 test client pre-esistente rotto (`test_save_measurement_proxy`) | Bassa | Probabilmente CSRF/payload. Risolvere con Fase 4. |
|
||||
| Pagina `task_complete` riepilogo: utente segnala riga vuota in alcuni scenari | Media | Da debuggare (rendering corretto via curl ma utente vede vuoto in browser, possibile interazione con sessione lot/serial). |
|
||||
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato (impatto deploy). |
|
||||
| Header `X-API-Key` rename a `X-Api-Key` | Bassa | Vedere se M2 lo richiede. |
|
||||
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 in M2. |
|
||||
| `Dockerfile.frontend`: `pybabel compile` via `uv run` non testato in build reale | Alta | Verificare al primo `docker compose build`. |
|
||||
| Smoke test in container Docker (non solo locale uvicorn+gunicorn) | Alta | Validare che i Dockerfile riscritti con `uv` buildino e girino correttamente prima di chiudere V2.0.0. |
|
||||
| `components/barcode_scanner.html` dipende da `html5-qrcode`, mai caricata | Media | Codice morto: il componente non è incluso da nessuna parte e il lettore che l'operatore usa è un campo di testo. Da rimuovere, o da completare portando la libreria in `static/vendor/` |
|
||||
| Pagina `task_complete`: riga vuota segnalata in alcuni scenari | Media | Segnalazione di aprile mai riprodotta. Da verificare durante il collaudo, ora che il flusso è cambiato |
|
||||
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato: impatta i deploy esistenti |
|
||||
| Header `X-API-Key` → `X-Api-Key` | Bassa | Breaking per i deploy esistenti |
|
||||
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 |
|
||||
| Test di carico a 20 tablet reali | Bassa | La capacità è dimensionata ma mai misurata sotto carico vero |
|
||||
|
||||
## Open per scelta utente prima della prossima sessione
|
||||
I quattro test rotti tracciati nello snapshot V2.0.0 non risultano più: la suite è a
|
||||
360 pass, 0 fail.
|
||||
|
||||
1. **Quale fase iniziare adesso?** Opzioni: Fase 2 (Supervisor — sblocca workflow), Fase 3 (block editor — independente), Fase 5 (import GAIA dati reali — sblocca demo).
|
||||
2. **Revisione decisioni aperte col cliente** — D-0.1 / D-0.2 / D-0.3 / D-0.6 prima di pianificare Fase 5 e 6.
|
||||
3. **Smoke test Docker** della nuova struttura V2.0.0 (`docker compose -f docker-compose.dev.yml up --build`) per validare i Dockerfile riscritti.
|
||||
4. **Test di carico** (k6/locust) a 20 VU su `/measure/save-measurement` per validare la scalatura worker (capacità annunciata: 20-30 tablet contemporanei).
|
||||
## Mappatura con la roadmap rev04 di aprile
|
||||
|
||||
## Stima tempi residui M1 (post-Fase 1)
|
||||
Per chi torna sui documenti di aprile e non ritrova le Fasi.
|
||||
|
||||
| Task | Stima full-time |
|
||||
| Fase rev04 | Che fine ha fatto |
|
||||
|---|---|
|
||||
| Fase 2 — Supervisor + override | 1 settimana |
|
||||
| Fase 3 — Block editor | 1.5 settimane |
|
||||
| Fase 4 — Workflow operatore | 2 settimane |
|
||||
| Fase 5 (M1) — Import-only GAIA | 1 settimana |
|
||||
| Deploy M1 demo | 0.5 settimane |
|
||||
| **Totale M1 residuo** | **~6 settimane** |
|
||||
| 1 — Stazioni per-tablet | Chiusa in V2.0.0, ampliata dal punto 7 |
|
||||
| 2 — Ruolo Capoturno + override | Assorbita dai punti 5 e 6: il ruolo `Supervisor` esiste e l'autorizzazione resta scritta sulla misura. L'override a token breve dipende da D-6 |
|
||||
| 3 — Editor ricetta a blocchi | Sostituita dal punto 2 (tipo di task dichiarato), che risolve il problema vero senza riscrivere l'editor |
|
||||
| 4 — Workflow operatore | Assorbita dai punti 3, 4, 10: timer e sequenza sono fatti, i tentativi sono il punto 4 |
|
||||
| 5 — Import GAIA | Diventata l'innesto del punto 6, ferma su D-1 e D-2 |
|
||||
| 6 — Deploy industriale (registry + Watchtower) | Ridimensionata: con una rete isolata l'aggiornamento automatico non ha senso. Restano immagini versionate portate a mano |
|
||||
| 7 — Hardening | Parzialmente assorbita: CSP, versioni congelate e impronte sono entrate col punto 12 |
|
||||
|
||||
## Stima tempi M2 (dopo feedback)
|
||||
|
||||
| Task | Stima |
|
||||
|---|---|
|
||||
| Aggiustamenti post-feedback | variabile (1-2 sett.) |
|
||||
| Fase 5 reale GAIA | 1-2 settimane |
|
||||
| Fase 6 deploy B | 1 settimana |
|
||||
| Fase 7 hardening | 1-2 settimane |
|
||||
| **Totale M2** | **~4-7 settimane** |
|
||||
|
||||
**Totale fino a produzione:** ~10-13 settimane full-time da oggi (2026-04-25), assumendo decisioni aperte risolte in tempo utile.
|
||||
Le vecchie decisioni `D-0.x` sono confluite nelle `D-x` qui sopra: D-0.1→D-1,
|
||||
D-0.2→D-2, D-0.4→D-5, D-0.5→D-9, D-0.6→D-6. D-0.8 (nome del ruolo capoturno) è
|
||||
chiusa: si chiama `Supervisor`. D-0.7 (auto-logout) era già risolta.
|
||||
|
||||
+157
-110
@@ -1,145 +1,192 @@
|
||||
# Stato Progetto TieMeasureFlow — V2.0.0
|
||||
# Stato Progetto TieMeasureFlow — V3.0.0
|
||||
|
||||
> Snapshot al 2026-04-27. Aggiornare ad ogni milestone.
|
||||
> Snapshot al 2026-07-28. Aggiornare ad ogni milestone.
|
||||
> Lo snapshot V2.0.0 è in [`../archive/STATO_PROGETTO_V2.0.0_2026-04-27.md`](../archive/STATO_PROGETTO_V2.0.0_2026-04-27.md).
|
||||
|
||||
## Versione corrente
|
||||
|
||||
**V2.0.0** (in sviluppo, branch `V2.0.0` come default su `git.tielogic.xyz`).
|
||||
**V3.0.0** (in sviluppo, branch `V3.0.0`). Versione precedente: `V2.0.0`.
|
||||
|
||||
Versione precedente di produzione: `V1.0.7`.
|
||||
In esercizio su `tieflow.tielogic.xyz` (Docker Compose + Traefik + Let's Encrypt),
|
||||
schema alla migrazione `010_meas_authorisation`.
|
||||
|
||||
## Sintesi esecutiva
|
||||
|
||||
Il sistema base (V1.0.7) è completo e collaudato: ricette, task, misurazioni, SPC, report PDF, gestione utenti, dashboard metrologist. La V2.0.0 in corso aggiunge il primo blocco della migrazione **rev04** (stazioni per-tablet) e ristruttura l'intero monorepo secondo lo standard `python-project-spec-design.md` (uv + `src/backend/` + `src/frontend/flask_app/`).
|
||||
V2.0.0 aveva chiuso stazioni per-tablet, ristrutturazione monorepo con `uv` e la
|
||||
tenuta multi-tablet. **V3.0.0 nasce dal sopralluogo del 28/07 e lavora sui quindici
|
||||
punti** raccolti in [`TieMeasureFlow_modifiche_2026-07-28.md`](../../TieMeasureFlow_modifiche_2026-07-28.md).
|
||||
|
||||
## Cosa funziona oggi (V2.0.0 — branch corrente)
|
||||
Il filo che li tiene insieme: fino a V2.0.0 il sistema *registrava* misure ma non
|
||||
*governava* la produzione. Le regole vivevano nell'interfaccia — una modale che si
|
||||
poteva chiudere, un timer che moriva al cambio pagina, un tipo di task dedotto dalla
|
||||
presenza di quote. V3.0.0 le sposta sul server, dove non si aggirano.
|
||||
|
||||
### Funzionalità ereditate da V1.0.7
|
||||
- Autenticazione username/password + API key per-utente, ruoli combinabili (Maker, MeasurementTec, Metrologist) + flag `is_admin`.
|
||||
- Recipe versioning copy-on-write: una nuova versione si crea solo se la corrente ha già measurements; altrimenti update in-place.
|
||||
- Editor ricette (Maker) con annotation editor Fabric.js (~1200 LOC, collaudato su tablet).
|
||||
- Workflow operatore tablet: select_recipe → task_list → task_execute → task_complete, con barcode scanner e numpad touch (input USB calibro con burst detection).
|
||||
- Calcolo pass/fail con limiti UTL/UWL/LWL/LTL.
|
||||
- Dashboard SPC: capability (Cp/Cpk/Pp/Ppk), control chart (UCL/LCL = mean ± 3σ), istogramma con curva normale, calcoli puro stdlib (no numpy).
|
||||
- Report PDF (WeasyPrint + Kaleido SVG).
|
||||
- Setup page protetta da `SETUP_PASSWORD` per inizializzazione DB e seed.
|
||||
- i18n IT/EN (Flask-Babel + Alpine.js JSON).
|
||||
- Tema light/dark via `Alpine.store('theme')` + localStorage.
|
||||
Tredici punti su quindici sono fatti e in esercizio. Uno è fermo sulle risposte del
|
||||
cliente, uno è fuori offerta.
|
||||
|
||||
### Aggiunte V2.0.0 (rev04 Fase 1 — Stazioni per-tablet)
|
||||
- Tabelle `stations` + `station_recipe_assignments` (Alembic migration `002_add_stations.py`).
|
||||
- Modelli ORM: `Station`, `StationRecipeAssignment` con vincolo unique `(station_id, recipe_id)`.
|
||||
- Schemas Pydantic: `StationCreate/Update/Response`, `StationRecipeAssignmentCreate/Response`, `RecipeSummary`.
|
||||
- Service `station_service` (CRUD + assegnazioni + cascade delete).
|
||||
- Router `/api/stations` con CRUD admin + endpoint operatore `GET /api/stations/by-code/{code}/recipes`.
|
||||
- Seed automatico `ST-DEFAULT` con tutte le ricette esistenti (idempotente).
|
||||
- Variabile env client `STATION_CODE` letta da `Config`, helper `APIClient.get_station_recipes()`.
|
||||
- Filtro `select_recipe`: il client mostra solo le ricette assegnate alla propria stazione, errore se `STATION_CODE` non configurato.
|
||||
- **GUI admin completa** in `/admin/stations`: tabella con search, modal create/edit, modal gestione assegnazioni ricette, conferma eliminazione, link in navbar (desktop + mobile).
|
||||
- 47 nuovi test (32 server + 15 client) tutti pass.
|
||||
## I quindici punti
|
||||
|
||||
### Aggiunte V2.0.0 (performance + multi-utente)
|
||||
- Gunicorn 5 workers × 4 thread (gthread) — capacità ~20 richieste concorrenti Flask, regge 20+ tablet.
|
||||
- Uvicorn 4 workers + `--proxy-headers --forwarded-allow-ips='*'`.
|
||||
- Rate limit middleware: identificazione IP reale via `X-Forwarded-For` → `X-Real-IP` → `request.client.host`.
|
||||
- Rate limit general 100 → 300 req/min/IP (per-tablet ora, non più condiviso).
|
||||
- Flask `ProxyFix(x_for=1, x_proto=1, x_host=1)` per IP reale dietro Nginx.
|
||||
- `APIClient` propaga `X-Forwarded-For` + `X-Real-IP` (sia JSON che multipart).
|
||||
- 12 test aggiuntivi (7 server + 5 client).
|
||||
| # | Punto | Stato | Dove vive |
|
||||
|---|---|---|---|
|
||||
| 1 | Memoria della produzione in corso | ✅ | `production_runs`, `production_service` |
|
||||
| 2 | Tipo di task esplicito | ✅ | `RecipeTask.task_type`, migrazione 007 |
|
||||
| 3 | Loop di misura e ripetizione | ✅ | `production-clock.js`, `production_service`, migrazione 008 |
|
||||
| 4 | Limite di tentativi prima del capoturno | ⛔ **Fermo su D-6, D-7, D-9** | — |
|
||||
| 5 | Avanzamento solo se in tolleranza | ✅ | `measurement_service.pending_authorisation`, migrazione 010 |
|
||||
| 6 | Fermo linea e Fine produzione con effetto | ✅ | `production_service`, `production_export_service` |
|
||||
| 7 | Gestione delle stazioni | ✅ | `station_service`, `/admin/stations` |
|
||||
| 8 | Tracciabilità obbligatoria | ✅ | `Recipe.requires_lot/serial`, migrazione 009 |
|
||||
| 9 | Blocco dell'inserimento manuale | ✅ | `Recipe.allow_manual_input`, `numpad.js` |
|
||||
| 10 | Interfaccia operatore: sequenza e conferme | ✅ | `/measure/start`, `task-progress`, `task_list`, `task_execute` |
|
||||
| 11 | Formattazione delle descrizioni | ✅ | filtro `rich_text`, `rich-text.js` |
|
||||
| 12 | Funzionamento senza internet | ✅ | `static/vendor/`, CSP in `app.py` |
|
||||
| 13 | Generazione task dalla scheda tecnica con l'AI | — **Fuori offerta** | — |
|
||||
| 14 | Stabilità del layout | ✅ | `.tmf-page` in `themes.css`, [`LAYOUT.md`](LAYOUT.md) |
|
||||
| 15 | Statistica separata dalla registrazione | ✅ | `@role_required("Metrologist")`, test dedicato |
|
||||
|
||||
### Aggiunte V2.0.0 (struttura monorepo)
|
||||
- `pyproject.toml` unico con extra `server`/`client`/`dev`. Niente più `requirements.txt`.
|
||||
- `uv.lock` (77 pacchetti) + `.python-version` (3.11) committati per build riproducibili.
|
||||
- Layout `src/backend/` + `src/frontend/flask_app/` (vedi sotto).
|
||||
- `Dockerfile` (root) + `Dockerfile.frontend` riscritti con `uv sync --frozen --no-dev --extra server|client`.
|
||||
- `docker-compose.{dev,}.yml` con build context `.`.
|
||||
- Alembic env.py aggiunge project root a `sys.path`; `script_location = %(here)s` resta valido.
|
||||
- `.dockerignore` aggiornato.
|
||||
## Cosa è entrato in V3.0.0
|
||||
|
||||
### Hardening post-restructure (smoke test 2026-04-26)
|
||||
### Produzione come stato, non come pulsante (punti 1, 6)
|
||||
|
||||
Sequenza di smoke test in locale (uvicorn + gunicorn + MySQL Docker) ha fatto emergere quattro regressioni che sarebbero rimaste invisibili al test suite:
|
||||
- Tabelle `production_runs` e `production_events`; una produzione si apre su una
|
||||
stazione, accumula eventi (`start`, `cycle_completed`, `task_measured`,
|
||||
`remeasure`, `line_stop`, `resume`, `close`) e si chiude.
|
||||
- Fermo linea, ripresa e fine produzione richiedono le credenziali del capoturno e
|
||||
**cambiano lo stato**: prima erano modali che si chiudevano.
|
||||
- La fine produzione emette il file di statistica dell'intera produzione
|
||||
(`production_export_service`), CSV con i delimitatori configurati, marcando le
|
||||
misure come esportate. Il punto d'innesto verso GAIA è lì accanto, in attesa di
|
||||
D-1 e D-2.
|
||||
|
||||
- **`src/backend/config.py`**: `env_file` era cwd-relative (`../../.env`). Rotto fuori da `src/backend/`. Risolto con percorso assoluto `Path(__file__).resolve().parents[2] / ".env"`.
|
||||
- **`src/backend/models/orm/__init__.py`**: `Station` e `StationRecipeAssignment` non erano esportati, quindi `Base.metadata.create_all` non creava le tabelle stations. Aggiunti agli import.
|
||||
- **`.env.example`**: `UPLOAD_DIR=server/uploads` era residuo della vecchia struttura → file landavano fuori dall'albero di progetto. Aggiornato a `UPLOAD_DIR=uploads`.
|
||||
- **Apostrofi italiani in template Alpine** (`l'utente`, `nell'eliminazione`, `nell'assegnazione`): chiudevano prematuramente JS string literals dentro `x-text` e blocchi `<script>`. Riscritti con delimitatori `"..."` o riformulazione testuale.
|
||||
### Il ciclo di misura (punto 3)
|
||||
|
||||
Inoltre **UX rework** della modale assegnazione ricette su `/admin/stations`: dropdown sostituita da layout a 2 colonne (disponibili / assegnate) con bottone inline `+ Assegna`, search filter, empty state esplicativo (mostrava silenziosamente lista vuota se tutte le ricette erano già assegnate).
|
||||
- L'intervallo della ricetta è calcolato dal server (`seconds_to_next_measurement`,
|
||||
`overdue`, `server_time`): sopravvive al cambio task, che è un ricaricamento di
|
||||
pagina, e non si azzera cambiando schermata.
|
||||
- Il conteggio **prosegue oltre lo zero**: il ritardo si vede, in rosso.
|
||||
- A scadenza l'operatore viene riportato al primo task di misura della ricetta, con
|
||||
cinque secondi di preavviso; chi sta già misurando viene lasciato finire.
|
||||
- Cicalino via WebAudio, nessun file audio da caricare.
|
||||
- `remeasure` distingue una seconda lettura dello stesso pezzo da un ciclo nuovo:
|
||||
girare il pezzo e rimisurare non deve far ripartire l'intervallo.
|
||||
|
||||
Test guard aggiunto: `test_template_js_syntax.py` valida ogni inline `<script>` E ogni espressione Alpine (`x-*`, `@*`, `:*`) della pagina con `node --check`. Cattura automaticamente il bug-class apostrofo. Skip se Node non è installato.
|
||||
### Le regole della ricetta (punti 8, 9)
|
||||
|
||||
## Layout repository (V2.0.0)
|
||||
- `requires_lot`, `requires_serial`, `allow_manual_input` per ricetta, verificati in
|
||||
`measurement_service`: **ogni porta d'ingresso passa di lì**, barcode compreso.
|
||||
- Con `allow_manual_input` a falso il tastierino non viene disegnato — non nascosto:
|
||||
il markup non esiste — e il server rifiuta comunque un valore digitato a mano.
|
||||
- Il rilevamento del calibro USB è stato reso più tollerante (Enter veloce), perché
|
||||
con la regola stretta una lettura corta come `9.5` veniva scambiata per digitazione.
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── pyproject.toml + uv.lock + .python-version
|
||||
├── Dockerfile (backend) + Dockerfile.frontend
|
||||
├── docker-compose.dev.yml + docker-compose.yml
|
||||
├── nginx/
|
||||
├── uploads/ # volume Docker
|
||||
├── docs/ # raggruppata e indicizzata
|
||||
│ ├── README.md (indice)
|
||||
│ ├── API.md / DEPLOYMENT.md / USER_GUIDE.md / I18N_SETUP.md
|
||||
│ ├── architecture/ # questo file + ROADMAP.md
|
||||
│ ├── archive/ # piani storici
|
||||
│ ├── specs/ # spec esterne (.docx)
|
||||
│ └── superpowers/plans/ # piani TDD dettagliati
|
||||
└── src/
|
||||
├── backend/
|
||||
│ ├── main.py / config.py / database.py
|
||||
│ ├── api/{routers,middleware}/
|
||||
│ ├── models/{orm,api}/
|
||||
│ ├── services/
|
||||
│ ├── migrations/
|
||||
│ ├── templates/
|
||||
│ └── tests/
|
||||
└── frontend/
|
||||
└── flask_app/
|
||||
├── app.py / config.py / compile_translations.py
|
||||
├── blueprints/ (auth, maker, measure, statistics, admin)
|
||||
├── services/ (api_client.py)
|
||||
├── templates/ + static/ + translations/
|
||||
└── tests/
|
||||
```
|
||||
### Il fuori tolleranza (punto 5)
|
||||
|
||||
## Smoke test status
|
||||
- Una quota fuori tolleranza e non autorizzata **blocca** il salvataggio della quota
|
||||
successiva e la chiusura del ciclo (409).
|
||||
- Conta l'**ultima** lettura di ogni quota: rimisurare la stessa quota resta
|
||||
possibile — il calibro scivola, il pezzo si riposiziona — e una lettura dentro i
|
||||
limiti scioglie il blocco senza chiamare nessuno.
|
||||
- L'autorizzazione del capoturno resta scritta sulla misura (`supervisor_id`,
|
||||
`authorised_at`) e finisce in due colonne del CSV di produzione: il file mostra il
|
||||
guasto **e** la decisione.
|
||||
- Quanti tentativi siano ammessi è il punto 4, non questo.
|
||||
|
||||
Validazione end-to-end in locale (2026-04-26):
|
||||
### L'interfaccia dell'operatore (punti 10, 11, 14, 15)
|
||||
|
||||
- ✅ MySQL container Docker up, schema creato, alembic stamp head OK
|
||||
- ✅ uvicorn `--reload` su :8000, `/api/health` risponde
|
||||
- ✅ Seed `/api/setup/seed` con `SETUP_PASSWORD=adriano77` → admin + 4 utenti demo + DEMO-001 + ST-DEFAULT con assegnazione automatica
|
||||
- ✅ Login `admin/admin123` via web, sessione persistente
|
||||
- ✅ `/admin/stations`: tabella, modal create/edit, modal gestione assegnazioni a 2 colonne con search, eliminazione con cascade
|
||||
- ✅ `/admin/users`, `/maker/recipes`, `/measure/select` (filtrato per stazione), `/statistics/dashboard`
|
||||
- ✅ Workflow MeasurementTec end-to-end: select_recipe → task_list → task_execute → task_complete (riepilogo con misure)
|
||||
- ✅ Hot reload Flask + uvicorn `--reload` + Tailwind watch attivi durante lo sviluppo
|
||||
- La ricetta si apre sul primo task (`/measure/start/<id>`), non su un elenco. La
|
||||
lista scende a secondo livello e mostra quali task sono rimasti incompiuti
|
||||
(`GET /api/measurements/task-progress`, contato per quota e non per tentativo).
|
||||
- «Fine ciclo misura» è visibile da subito e spento finché mancano quote, con
|
||||
scritto quante ne mancano.
|
||||
- Descrizioni con `**grassetto**` e a capo: marcatura, non HTML, così la
|
||||
sanificazione è per costruzione.
|
||||
- Una cornice sola per tutte le viste, allineata alla navbar — vedi [`LAYOUT.md`](LAYOUT.md).
|
||||
- Nessun percorso dell'operatore porta alla statistica, ed è un test.
|
||||
|
||||
### Fuori dalla rete (punto 12)
|
||||
|
||||
- Alpine, Plotly, PDF.js (+ worker), Fabric e i font sono nell'installazione, con
|
||||
versione nel nome e impronta SHA-256 verificata da un test.
|
||||
- Content-Security-Policy a sola origine locale servita **dal client Flask**: prima
|
||||
esisteva solo sul backend, cioè sulle risposte API e non sulle pagine.
|
||||
- Tailwind fissato a `3.4.19` nel `Dockerfile.frontend`.
|
||||
|
||||
## Migrazioni
|
||||
|
||||
| # | Contenuto |
|
||||
|---|---|
|
||||
| 001–004 | image_path, stazioni, intervallo di misura + auto-logout, `input_duration_ms` |
|
||||
| 005–006 | `production_runs`, misure legate alla produzione |
|
||||
| 007 | `task_type` dichiarato |
|
||||
| 008 | tipi di evento del ciclo + `task_id` sull'evento |
|
||||
| 009 | `requires_lot`, `requires_serial`, `allow_manual_input` |
|
||||
| 010 | `supervisor_id`, `authorised_at` sulla misura |
|
||||
|
||||
Le 008 e 010 usano il *batch mode* per compatibilità SQLite (i test) e sono state
|
||||
verificate anche in resa MySQL (`alembic upgrade X:Y --sql`) prima del deploy.
|
||||
|
||||
La 009 imposta `allow_manual_input = 1` sulle ricette esistenti: conserva il
|
||||
comportamento in essere invece di irrigidire di colpo ricette già in uso. È una
|
||||
scelta, e va confermata dal cliente ricetta per ricetta.
|
||||
|
||||
## Test status
|
||||
| Backend (`src/backend/tests/`) | 127 | 3 | Fail pre-esistenti: `test_recipes` (2) + `test_tasks` (1). Nessuno introdotto dalla V2.0.0. |
|
||||
| Frontend (`src/frontend/flask_app/tests/`) | 46 | 1 | +2 test post-restructure (`test_template_js_syntax.py`). Fail pre-esistente: `test_save_measurement_proxy`. |
|
||||
| **Totale** | **173** | **4** | Tutti i fallimenti tracciati come tech debt da risolvere. |
|
||||
|
||||
## Stack confermato
|
||||
| | Test | Fail |
|
||||
|---|---|---|
|
||||
| Backend (`src/backend/tests/`) | 212 | 0 |
|
||||
| Frontend (`src/frontend/flask_app/tests/`) | 148 | 0 |
|
||||
| **Totale** | **360** | **0** |
|
||||
|
||||
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 + WeasyPrint + Plotly/Kaleido.
|
||||
- **Frontend:** Flask + Jinja2 + Alpine.js + TailwindCSS + Fabric.js 5.3.1 + html5-qrcode + Plotly.js + Flask-Babel.
|
||||
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt SSL.
|
||||
- **Tooling:** uv (package mgmt), pytest + pytest-asyncio + httpx + aiosqlite (test).
|
||||
I quattro fallimenti pre-esistenti tracciati nello snapshot V2.0.0 non ci sono più.
|
||||
|
||||
Tre file di test non renderizzano niente e leggono i sorgenti, perché guardano
|
||||
proprietà che sopravvivono solo se qualcuno le controlla:
|
||||
|
||||
| File | Cosa impedisce |
|
||||
|---|---|
|
||||
| `test_offline.py` | Una libreria caricata dalla rete, un worker PDF.js lasciato sul CDN, una libreria sostituita senza aggiornare l'impronta |
|
||||
| `test_layout_shell.py` | Una vista che torna a dichiararsi la propria larghezza |
|
||||
| `test_template_js_syntax.py` | Una traduzione con l'apostrofo dentro una stringa JS a virgolette singole, che spegne Alpine su tutta la pagina |
|
||||
|
||||
## Cosa non è stato provato
|
||||
|
||||
Va detto perché non si confonda «i test passano» con «funziona in reparto».
|
||||
|
||||
- **Il collaudo sul campo non è mai stato percorso.** Le ricette `COLLAUDO-A` e
|
||||
`COLLAUDO-B` sono state seminate sul sistema in esercizio (`scripts/seed_collaudo.py`)
|
||||
insieme a un utente `capoturno`, ma nessuno ha ancora guidato il flusso su un
|
||||
tablet vero.
|
||||
- **Il punto 14 non è stato riprodotto su un dispositivo**: è dimostrato che il
|
||||
layout *poteva* muoversi per quattro motivi e che ora non può più, non che
|
||||
l'operatore vedesse esattamente quelli.
|
||||
- **Il punto 12 non è stato provato a rete staccata.** È dimostrato che nessuna
|
||||
risorsa esterna viene richiesta. Il giro con la rete staccata va fatto, ed è ora il
|
||||
momento giusto perché la CSP è appena entrata in vigore.
|
||||
- **Il cicalino** è il suono del browser. Se serva una colonnina luminosa è D-5.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 +
|
||||
WeasyPrint + Plotly/Kaleido. 12 router.
|
||||
- **Frontend:** Flask + Jinja2 + Alpine.js 3.15.12 + TailwindCSS 3.4.19 +
|
||||
Fabric.js 5.3.1 + PDF.js 3.11.174 + Plotly.js 2.32.0 + Flask-Babel —
|
||||
**tutte copie locali**, nessun CDN.
|
||||
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt.
|
||||
- **Tooling:** uv, pytest + pytest-asyncio + httpx + aiosqlite.
|
||||
|
||||
## Decisioni architetturali rilevanti
|
||||
|
||||
| Decisione | Stato | Note |
|
||||
|---|---|---|
|
||||
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, USB calipers/barcode, Fabric.js editor, i18n Babel collaudato. Vedi conversazione 2026-04-25. |
|
||||
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, no microservizi. Nessuno stub `nats_client/` creato. |
|
||||
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo 4-5gg refactor + rotture client. Eventuale v2 API in M2. |
|
||||
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare costa 50+ punti di codice + breaking per deploy. Rivedere in M2. |
|
||||
| Variabili `.env` (DB_HOST, SERVER_PORT, ...) | **Mantenute attuali** | Rename a SERVICE_NAME/SERVICE_DOMAIN/API_KEY rinviato (impatta deploy esistenti). |
|
||||
| Le regole di misura stanno sul server, non sullo schermo | **Confermata (V3.0.0)** | Tracciabilità, inserimento manuale, fuori tolleranza: la schermata può nasconderle, il server le rifiuta. |
|
||||
| Una app di stazione per PC, dati sul server | **Decisa il 28/07** (D-3) | Validazione con l'IT di Tràfilo aperta come D-4. |
|
||||
| Descrizioni in marcatura, non in HTML | **Confermata** | Niente HTML accettato in ingresso: la sanificazione è per costruzione, non per filtro. |
|
||||
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, calibri USB, editor Fabric.js, i18n Babel collaudato. |
|
||||
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, nessun microservizio. |
|
||||
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo alto, rotture client. Eventuale API v2. |
|
||||
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare è breaking per i deploy esistenti. |
|
||||
|
||||
## Branch git
|
||||
|
||||
- **Default:** `V2.0.0` (lavoro corrente)
|
||||
- **Mantenuti:** `V1.0.0` … `V1.0.7` (release branches storiche)
|
||||
- **Mergiato e chiuso:** `feature/rev04-phase1-stations` (in `V2.0.0` con commit `ea8e468`)
|
||||
- **Corrente:** `V3.0.0`
|
||||
- **Precedenti:** `V2.0.0`, `V1.0.0` … `V1.0.7` (release storiche)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Stato Progetto TieMeasureFlow — V2.0.0
|
||||
|
||||
> Snapshot al 2026-04-27. Aggiornare ad ogni milestone.
|
||||
|
||||
## Versione corrente
|
||||
|
||||
**V2.0.0** (in sviluppo, branch `V2.0.0` come default su `git.tielogic.xyz`).
|
||||
|
||||
Versione precedente di produzione: `V1.0.7`.
|
||||
|
||||
## Sintesi esecutiva
|
||||
|
||||
Il sistema base (V1.0.7) è completo e collaudato: ricette, task, misurazioni, SPC, report PDF, gestione utenti, dashboard metrologist. La V2.0.0 in corso aggiunge il primo blocco della migrazione **rev04** (stazioni per-tablet) e ristruttura l'intero monorepo secondo lo standard `python-project-spec-design.md` (uv + `src/backend/` + `src/frontend/flask_app/`).
|
||||
|
||||
## Cosa funziona oggi (V2.0.0 — branch corrente)
|
||||
|
||||
### Funzionalità ereditate da V1.0.7
|
||||
- Autenticazione username/password + API key per-utente, ruoli combinabili (Maker, MeasurementTec, Metrologist) + flag `is_admin`.
|
||||
- Recipe versioning copy-on-write: una nuova versione si crea solo se la corrente ha già measurements; altrimenti update in-place.
|
||||
- Editor ricette (Maker) con annotation editor Fabric.js (~1200 LOC, collaudato su tablet).
|
||||
- Workflow operatore tablet: select_recipe → task_list → task_execute → task_complete, con barcode scanner e numpad touch (input USB calibro con burst detection).
|
||||
- Calcolo pass/fail con limiti UTL/UWL/LWL/LTL.
|
||||
- Dashboard SPC: capability (Cp/Cpk/Pp/Ppk), control chart (UCL/LCL = mean ± 3σ), istogramma con curva normale, calcoli puro stdlib (no numpy).
|
||||
- Report PDF (WeasyPrint + Kaleido SVG).
|
||||
- Setup page protetta da `SETUP_PASSWORD` per inizializzazione DB e seed.
|
||||
- i18n IT/EN (Flask-Babel + Alpine.js JSON).
|
||||
- Tema light/dark via `Alpine.store('theme')` + localStorage.
|
||||
|
||||
### Aggiunte V2.0.0 (rev04 Fase 1 — Stazioni per-tablet)
|
||||
- Tabelle `stations` + `station_recipe_assignments` (Alembic migration `002_add_stations.py`).
|
||||
- Modelli ORM: `Station`, `StationRecipeAssignment` con vincolo unique `(station_id, recipe_id)`.
|
||||
- Schemas Pydantic: `StationCreate/Update/Response`, `StationRecipeAssignmentCreate/Response`, `RecipeSummary`.
|
||||
- Service `station_service` (CRUD + assegnazioni + cascade delete).
|
||||
- Router `/api/stations` con CRUD admin + endpoint operatore `GET /api/stations/by-code/{code}/recipes`.
|
||||
- Seed automatico `ST-DEFAULT` con tutte le ricette esistenti (idempotente).
|
||||
- Variabile env client `STATION_CODE` letta da `Config`, helper `APIClient.get_station_recipes()`.
|
||||
- Filtro `select_recipe`: il client mostra solo le ricette assegnate alla propria stazione, errore se `STATION_CODE` non configurato.
|
||||
- **GUI admin completa** in `/admin/stations`: tabella con search, modal create/edit, modal gestione assegnazioni ricette, conferma eliminazione, link in navbar (desktop + mobile).
|
||||
- 47 nuovi test (32 server + 15 client) tutti pass.
|
||||
|
||||
### Aggiunte V2.0.0 (performance + multi-utente)
|
||||
- Gunicorn 5 workers × 4 thread (gthread) — capacità ~20 richieste concorrenti Flask, regge 20+ tablet.
|
||||
- Uvicorn 4 workers + `--proxy-headers --forwarded-allow-ips='*'`.
|
||||
- Rate limit middleware: identificazione IP reale via `X-Forwarded-For` → `X-Real-IP` → `request.client.host`.
|
||||
- Rate limit general 100 → 300 req/min/IP (per-tablet ora, non più condiviso).
|
||||
- Flask `ProxyFix(x_for=1, x_proto=1, x_host=1)` per IP reale dietro Nginx.
|
||||
- `APIClient` propaga `X-Forwarded-For` + `X-Real-IP` (sia JSON che multipart).
|
||||
- 12 test aggiuntivi (7 server + 5 client).
|
||||
|
||||
### Aggiunte V2.0.0 (struttura monorepo)
|
||||
- `pyproject.toml` unico con extra `server`/`client`/`dev`. Niente più `requirements.txt`.
|
||||
- `uv.lock` (77 pacchetti) + `.python-version` (3.11) committati per build riproducibili.
|
||||
- Layout `src/backend/` + `src/frontend/flask_app/` (vedi sotto).
|
||||
- `Dockerfile` (root) + `Dockerfile.frontend` riscritti con `uv sync --frozen --no-dev --extra server|client`.
|
||||
- `docker-compose.{dev,}.yml` con build context `.`.
|
||||
- Alembic env.py aggiunge project root a `sys.path`; `script_location = %(here)s` resta valido.
|
||||
- `.dockerignore` aggiornato.
|
||||
|
||||
### Hardening post-restructure (smoke test 2026-04-26)
|
||||
|
||||
Sequenza di smoke test in locale (uvicorn + gunicorn + MySQL Docker) ha fatto emergere quattro regressioni che sarebbero rimaste invisibili al test suite:
|
||||
|
||||
- **`src/backend/config.py`**: `env_file` era cwd-relative (`../../.env`). Rotto fuori da `src/backend/`. Risolto con percorso assoluto `Path(__file__).resolve().parents[2] / ".env"`.
|
||||
- **`src/backend/models/orm/__init__.py`**: `Station` e `StationRecipeAssignment` non erano esportati, quindi `Base.metadata.create_all` non creava le tabelle stations. Aggiunti agli import.
|
||||
- **`.env.example`**: `UPLOAD_DIR=server/uploads` era residuo della vecchia struttura → file landavano fuori dall'albero di progetto. Aggiornato a `UPLOAD_DIR=uploads`.
|
||||
- **Apostrofi italiani in template Alpine** (`l'utente`, `nell'eliminazione`, `nell'assegnazione`): chiudevano prematuramente JS string literals dentro `x-text` e blocchi `<script>`. Riscritti con delimitatori `"..."` o riformulazione testuale.
|
||||
|
||||
Inoltre **UX rework** della modale assegnazione ricette su `/admin/stations`: dropdown sostituita da layout a 2 colonne (disponibili / assegnate) con bottone inline `+ Assegna`, search filter, empty state esplicativo (mostrava silenziosamente lista vuota se tutte le ricette erano già assegnate).
|
||||
|
||||
Test guard aggiunto: `test_template_js_syntax.py` valida ogni inline `<script>` E ogni espressione Alpine (`x-*`, `@*`, `:*`) della pagina con `node --check`. Cattura automaticamente il bug-class apostrofo. Skip se Node non è installato.
|
||||
|
||||
## Layout repository (V2.0.0)
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── pyproject.toml + uv.lock + .python-version
|
||||
├── Dockerfile (backend) + Dockerfile.frontend
|
||||
├── docker-compose.dev.yml + docker-compose.yml
|
||||
├── nginx/
|
||||
├── uploads/ # volume Docker
|
||||
├── docs/ # raggruppata e indicizzata
|
||||
│ ├── README.md (indice)
|
||||
│ ├── API.md / DEPLOYMENT.md / USER_GUIDE.md / I18N_SETUP.md
|
||||
│ ├── architecture/ # questo file + ROADMAP.md
|
||||
│ ├── archive/ # piani storici
|
||||
│ ├── specs/ # spec esterne (.docx)
|
||||
│ └── superpowers/plans/ # piani TDD dettagliati
|
||||
└── src/
|
||||
├── backend/
|
||||
│ ├── main.py / config.py / database.py
|
||||
│ ├── api/{routers,middleware}/
|
||||
│ ├── models/{orm,api}/
|
||||
│ ├── services/
|
||||
│ ├── migrations/
|
||||
│ ├── templates/
|
||||
│ └── tests/
|
||||
└── frontend/
|
||||
└── flask_app/
|
||||
├── app.py / config.py / compile_translations.py
|
||||
├── blueprints/ (auth, maker, measure, statistics, admin)
|
||||
├── services/ (api_client.py)
|
||||
├── templates/ + static/ + translations/
|
||||
└── tests/
|
||||
```
|
||||
|
||||
## Smoke test status
|
||||
|
||||
Validazione end-to-end in locale (2026-04-26):
|
||||
|
||||
- ✅ MySQL container Docker up, schema creato, alembic stamp head OK
|
||||
- ✅ uvicorn `--reload` su :8000, `/api/health` risponde
|
||||
- ✅ Seed `/api/setup/seed` con `SETUP_PASSWORD=adriano77` → admin + 4 utenti demo + DEMO-001 + ST-DEFAULT con assegnazione automatica
|
||||
- ✅ Login `admin/admin123` via web, sessione persistente
|
||||
- ✅ `/admin/stations`: tabella, modal create/edit, modal gestione assegnazioni a 2 colonne con search, eliminazione con cascade
|
||||
- ✅ `/admin/users`, `/maker/recipes`, `/measure/select` (filtrato per stazione), `/statistics/dashboard`
|
||||
- ✅ Workflow MeasurementTec end-to-end: select_recipe → task_list → task_execute → task_complete (riepilogo con misure)
|
||||
- ✅ Hot reload Flask + uvicorn `--reload` + Tailwind watch attivi durante lo sviluppo
|
||||
|
||||
## Test status
|
||||
| Backend (`src/backend/tests/`) | 127 | 3 | Fail pre-esistenti: `test_recipes` (2) + `test_tasks` (1). Nessuno introdotto dalla V2.0.0. |
|
||||
| Frontend (`src/frontend/flask_app/tests/`) | 46 | 1 | +2 test post-restructure (`test_template_js_syntax.py`). Fail pre-esistente: `test_save_measurement_proxy`. |
|
||||
| **Totale** | **173** | **4** | Tutti i fallimenti tracciati come tech debt da risolvere. |
|
||||
|
||||
## Stack confermato
|
||||
|
||||
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 + WeasyPrint + Plotly/Kaleido.
|
||||
- **Frontend:** Flask + Jinja2 + Alpine.js + TailwindCSS + Fabric.js 5.3.1 + html5-qrcode + Plotly.js + Flask-Babel.
|
||||
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt SSL.
|
||||
- **Tooling:** uv (package mgmt), pytest + pytest-asyncio + httpx + aiosqlite (test).
|
||||
|
||||
## Decisioni architetturali rilevanti
|
||||
|
||||
| Decisione | Stato | Note |
|
||||
|---|---|---|
|
||||
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, USB calipers/barcode, Fabric.js editor, i18n Babel collaudato. Vedi conversazione 2026-04-25. |
|
||||
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, no microservizi. Nessuno stub `nats_client/` creato. |
|
||||
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo 4-5gg refactor + rotture client. Eventuale v2 API in M2. |
|
||||
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare costa 50+ punti di codice + breaking per deploy. Rivedere in M2. |
|
||||
| Variabili `.env` (DB_HOST, SERVER_PORT, ...) | **Mantenute attuali** | Rename a SERVICE_NAME/SERVICE_DOMAIN/API_KEY rinviato (impatta deploy esistenti). |
|
||||
|
||||
## Branch git
|
||||
|
||||
- **Default:** `V2.0.0` (lavoro corrente)
|
||||
- **Mantenuti:** `V1.0.0` … `V1.0.7` (release branches storiche)
|
||||
- **Mergiato e chiuso:** `feature/rev04-phase1-stations` (in `V2.0.0` con commit `ea8e468`)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,474 @@
|
||||
# Integrazione di VisionSuite in TieMeasureFlow — design
|
||||
|
||||
> Progetto: TieMeasureFlow V3.0.0 → V3.1.0
|
||||
> Data: 2026-08-16
|
||||
> Stato: design approvato, piano di implementazione da scrivere
|
||||
|
||||
## Perché
|
||||
|
||||
TieMeasureFlow oggi registra misure prese a mano, con il calibro USB o con il
|
||||
tastierino. Il passo successivo che il cliente chiede è misurare con una camera:
|
||||
acquisire l'immagine di un pezzo e ricavarne le quote, oppure confrontarne il
|
||||
profilo con il disegno DXF di riferimento.
|
||||
|
||||
La visione artificiale non va scritta qui. Tielogic ha già
|
||||
[VisionSuite](ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git), un
|
||||
monorepo di moduli riusabili il cui README indica esplicitamente TieMeasureFlow
|
||||
fra i consumatori previsti. Questo documento descrive come agganciarlo.
|
||||
|
||||
Il terreno è in parte già preparato. La migrazione `007_task_type` ha introdotto
|
||||
il tipo di task dichiarato e l'enum contiene fin da allora due valori che nessuno
|
||||
produce né consuma ancora:
|
||||
|
||||
```python
|
||||
TASK_TYPES = ("note", "measure", "drawing", "xf_compare", "camera_measure")
|
||||
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
||||
```
|
||||
|
||||
Questo lavoro li riempie.
|
||||
|
||||
## Che cosa entra e che cosa resta fuori
|
||||
|
||||
**Entra:** l'acquisizione da camera, l'esecuzione di un programma di visione su
|
||||
quell'immagine — pattern matching più misure, oppure pattern matching più
|
||||
confronto DXF — e l'ingresso dei risultati nel modello di misura che già esiste.
|
||||
|
||||
**Resta fuori, per ora:** i semafori USB. Sono previsti dall'architettura — è
|
||||
l'agente di stazione a possederli, insieme alle camere — ma non fanno parte di
|
||||
questa consegna. Il confine è disegnato perché ci entrino senza ristrutturazioni.
|
||||
|
||||
## Le otto decisioni
|
||||
|
||||
Sono le scelte prese in fase di brainstorming, con il motivo per cui sono state
|
||||
prese. Chi legge fra sei mesi ha bisogno del motivo più della scelta.
|
||||
|
||||
### 1. La visione gira sia sul server sia sulla stazione
|
||||
|
||||
Il motore è un componente unico distribuito in due posti: accanto al server e
|
||||
dentro l'agente di stazione. Questo dà la resilienza — se cade il collegamento,
|
||||
la stazione continua a misurare — e permette al server di elaborare in fase di
|
||||
produzione, di comporre ricette per conto del Maker e di rifare analisi su
|
||||
immagini archiviate.
|
||||
|
||||
È coerente con la decisione **D-3**, chiusa il 28/07: dati sul server, app di
|
||||
stazione su ogni PC.
|
||||
|
||||
### 2. Server e stazione montano la stessa identica versione di VisionSuite
|
||||
|
||||
Non è una raccomandazione di build: è un vincolo metrologico. Se il server monta
|
||||
un commit e una stazione ne monta un altro, lo stesso pezzo con lo stesso grafo
|
||||
può dare due numeri diversi, e nessuno se ne accorge finché non arriva una
|
||||
contestazione.
|
||||
|
||||
Da qui tre obblighi che il design deve garantire:
|
||||
|
||||
- VisionSuite entra come **sottomodulo git su un commit preciso**, lo stesso per
|
||||
entrambi — la forma che il README di VisionSuite prescrive
|
||||
- ogni misura porta scritto **con quale versione del motore** è stata prodotta
|
||||
- una stazione che monta una versione diversa da quella del server **si rifiuta
|
||||
di misurare** e lo dichiara, invece di produrre numeri plausibili e sbagliati
|
||||
|
||||
### 3. L'agente di stazione è sottile
|
||||
|
||||
L'agente parla ai device, consegna immagini e — più avanti — piloterà i semafori.
|
||||
Non contiene decisioni di visione.
|
||||
|
||||
La conseguenza pratica è l'impacchettamento: un agente che non contiene
|
||||
VisionSuite non contiene nemmeno PyTorch. Distribuirlo come eseguibile su Windows
|
||||
o come container su Linux diventa realistico, e la scelta su driver e formato può
|
||||
restare tardiva — che è bene, perché dipende dalla **D-4**, ancora aperta.
|
||||
|
||||
L'agente incorpora il motore solo quando la stazione è configurata per elaborare
|
||||
in locale, e in quel caso resta comunque un guscio: riceve un grafo, lo esegue,
|
||||
restituisce le uscite.
|
||||
|
||||
### 4. Il server non importa mai VisionSuite
|
||||
|
||||
La visione sul lato server vive in un **servizio separato** nello stesso compose,
|
||||
il *vision worker*. Il server FastAPI continua a fare quello che fa —
|
||||
autenticazione, ricette, misure, statistica — e resta leggero.
|
||||
|
||||
Quattro ragioni concrete: l'immagine dell'API non passa da poche centinaia di
|
||||
megabyte a cinque gigabyte; un aggiornamento di VisionSuite non richiede di
|
||||
riavviare l'API in produzione; un'elaborazione che va in crash non porta giù le
|
||||
richieste degli altri tablet; il giorno in cui la visione chiede una GPU o una
|
||||
macchina propria si sposta senza toccare il resto.
|
||||
|
||||
Il confine va difeso: se un domani il server importasse VisionSuite per una
|
||||
scorciatoia, il vantaggio si perde in silenzio.
|
||||
|
||||
### 5. La configurazione dei device sta sul PC
|
||||
|
||||
Le camere — e domani i semafori — appartengono alla macchina, non alla ricetta.
|
||||
Il server ne tiene uno **specchio** in sola lettura, popolato dall'agente quando
|
||||
si connette, perché il Maker deve poter scegliere un device da un elenco stando
|
||||
alla scrivania.
|
||||
|
||||
Un device che l'agente non dichiara più è **offline**, non cancellato: una
|
||||
ricetta che lo referenzia deve continuare a esistere e a spiegare perché non può
|
||||
girare.
|
||||
|
||||
### 6. Dove si esegue lo decide la configurazione della stazione
|
||||
|
||||
Ogni PC dichiara se elabora in locale o delega al server, nella stessa
|
||||
configurazione dove già stanno i device. Il default è il server.
|
||||
|
||||
Le macchine capaci si prendono il carico e non mandano immagini in rete; quelle
|
||||
deboli delegano. Se il server non risponde e la stazione ha il motore, la
|
||||
stazione ripiega in locale e lo segna nel risultato. Un impianto misto si governa
|
||||
senza toccare le ricette.
|
||||
|
||||
La ricetta non dice mai dove gira: legare una scelta di impianto a un documento
|
||||
di produzione significa dover rimettere mano alle ricette per spostare il carico.
|
||||
|
||||
### 7. Il verdetto lo dà TieMeasureFlow, non il grafo
|
||||
|
||||
Gli strumenti di quota di `vs-task` hanno anche loro `nominal`, `plus`, `minus` e
|
||||
un `outcome`. Quell'esito viene ignorato e quei limiti restano vuoti: il grafo
|
||||
produce numeri, `measurement_service` decide se sono buoni.
|
||||
|
||||
Due giudici sullo stesso valore è il modo più rapido per ottenere un CSV che si
|
||||
contraddice e una contestazione che non si sa come chiudere.
|
||||
|
||||
La conseguenza è che tutto il resto continua a funzionare senza modifiche:
|
||||
pass/warning/fail, gate del fuori tolleranza con autorizzazione del capoturno,
|
||||
Cp/Cpk, carta di controllo, colonne del CSV di produzione. Una quota da camera è
|
||||
una misura come le altre.
|
||||
|
||||
### 8. La sorgente immagine è un'astrazione a tre facce
|
||||
|
||||
Il motore riceve un'immagine e non sa da dove venga. Le tre sorgenti sono: dal
|
||||
vivo da un device tramite l'agente, un'immagine di riferimento salvata sul
|
||||
server, un file caricato dal Maker.
|
||||
|
||||
Non è un'astrazione nostra: è quella che VisionSuite si è già data — *«espongono
|
||||
una sorgente, non una telecamera: è questo che permette di collaudare un impianto
|
||||
a cinque telecamere senza avere le cinque telecamere sul tavolo, e di scrivere
|
||||
prove che danno sempre lo stesso risultato»*.
|
||||
|
||||
Sblocca tre cose che altrimenti non ci sarebbero:
|
||||
|
||||
- il Maker compone alla scrivania, senza tenere occupata una stazione né avere il
|
||||
pezzo in mano
|
||||
- una ricetta si prova prima di andare in produzione, invece di scoprirne i
|
||||
difetti misurando
|
||||
- diventa possibile una **prova di non regressione**: salvando accanto
|
||||
all'immagine il risultato atteso, si riesegue l'intero parco ricette dopo un
|
||||
aggiornamento di VisionSuite e si vede quali quote si sono mosse
|
||||
|
||||
Quest'ultima è la rete di sicurezza che rende sostenibile aggiornare il
|
||||
sottomodulo, con un motore che sta su due host.
|
||||
|
||||
> **Non ancora consegnata.** La colonna `expected_json` esiste, ma nessun
|
||||
> percorso la scrive — nemmeno `preview`, che è l'unico posto dove un grafo gira
|
||||
> davvero su un'immagine. La prova di non regressione è quindi **promessa da
|
||||
> questo documento e non mantenuta da nessuna riga di codice**. Diventa una
|
||||
> consegna nominata del piano 1b: «`preview` può promuovere le proprie uscite a
|
||||
> `expected_json`». Finché non è fatta, questa decisione non va dichiarata
|
||||
> completa.
|
||||
|
||||
## Architettura
|
||||
|
||||
### I componenti
|
||||
|
||||
| Componente | Nuovo | Dove vive | Responsabilità |
|
||||
|---|---|---|---|
|
||||
| `vendor/visionsuite` | sì | sottomodulo git | i pacchetti `vs-core`, `vs-camera`, `vs-pm2d`, `vs-measure`, `vs-dxf`, su un commit fisso |
|
||||
| Vision runner | sì | libreria condivisa | immagine + grafo + calibrazione → quote e artefatti. Unica implementazione, due host |
|
||||
| Vision worker | sì | container nel compose | espone il runner dietro una API interna. Il container pesante |
|
||||
| Agente di stazione | sì | PC di reparto | possiede i device, acquisisce, apre la connessione verso il server, incorpora il runner se configurato |
|
||||
| Backend FastAPI | modificato | container esistente | tipi di task, grafo sul task, registro device, inoltro, ingresso risultati. **Nessun import di VisionSuite** |
|
||||
| Frontend Flask | modificato | container esistente | Maker: device, fotogramma, aggancio quote. Measure: schermata del task con camera |
|
||||
|
||||
L'aggancio del sottomodulo segue la forma prescritta dal README di VisionSuite:
|
||||
dipendenza dai soli pacchetti che servono, dichiarati in path editable.
|
||||
|
||||
```toml
|
||||
[tool.uv.sources]
|
||||
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
|
||||
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true }
|
||||
```
|
||||
|
||||
I due host non montano lo stesso insieme, ed è il punto dell'intera struttura di
|
||||
VisionSuite:
|
||||
|
||||
| Pacchetto | Worker | Agente | Perché |
|
||||
|---|---|---|---|
|
||||
| `vs-core` | sì | sì | strutture di base e calibrazione, servono a entrambi |
|
||||
| `vs-pm2d` | sì | solo se elabora in locale | pattern matching |
|
||||
| `vs-measure` | sì | solo se elabora in locale | primitive e quote |
|
||||
| `vs-dxf` | sì | solo se elabora in locale | confronto col disegno. È quello che porta PyTorch |
|
||||
| `vs-camera` | **no** | sì | il worker non tocca hardware: riceve immagini già acquisite |
|
||||
|
||||
Un agente su una stazione che delega al server monta quindi due soli pacchetti,
|
||||
`vs-core` e `vs-camera`, e resta leggero. È la differenza fra un eseguibile
|
||||
distribuibile e un'installazione da cinque gigabyte su ogni PC di reparto.
|
||||
|
||||
I nomi di importazione non sono uniformi — `vs-core` si importa come
|
||||
`visionsuite`, `vs-pm2d` come `pm2d`, `vs-dxf` come `dxf_compare` — ed è una
|
||||
stortura nota e dichiarata a monte, non un errore da correggere qui.
|
||||
|
||||
### Il flusso di una misura, esecuzione sul server
|
||||
|
||||
```
|
||||
operatore apre il task
|
||||
→ client chiede al server di eseguire
|
||||
→ server chiede all'agente un fotogramma sul device dichiarato
|
||||
→ agente acquisisce e carica l'immagine
|
||||
→ server passa immagine e grafo al worker
|
||||
→ worker restituisce le uscite
|
||||
→ server applica pass/fail, gate del fuori tolleranza, salva in measurements
|
||||
→ client mostra esito e immagine con le zone dove ha guardato
|
||||
```
|
||||
|
||||
### Il flusso con esecuzione in locale
|
||||
|
||||
Il giro si accorcia: il server manda il grafo all'agente, l'agente acquisisce ed
|
||||
esegue, e rimanda uscite più immagine. **Da lì in poi il server fa le stesse
|
||||
identiche cose.** Il verdetto, il gate e il salvataggio non cambiano mai posto:
|
||||
cambia chi calcola i numeri, non chi decide se sono buoni.
|
||||
|
||||
È questo che rende le due strade equivalenti a valle, e che permette di cambiare
|
||||
la configurazione di una stazione senza toccare né il frontend né le ricette.
|
||||
|
||||
## Modello dati
|
||||
|
||||
### Il grafo di visione
|
||||
|
||||
`recipe_tasks` prende **`vision_json`** (JSON, nullable): il grafo di `vs-task`
|
||||
serializzato, opaco al server.
|
||||
|
||||
Il versionamento arriva gratis. Il copy-on-write delle ricette copia già i task
|
||||
in profondità, quindi una modifica al grafo su una ricetta con misure produce una
|
||||
versione nuova come qualunque altra modifica; in `recipe_service` è un campo in
|
||||
più nella copia, non una logica nuova.
|
||||
|
||||
Non si riusa `annotations_json`, che sta lì accanto: le annotazioni sono un
|
||||
disegno per l'operatore, il grafo è un programma da eseguire. Hanno cicli di vita
|
||||
e regole di validazione diversi, e mescolarli significa non poter validare né
|
||||
l'uno né l'altro.
|
||||
|
||||
### Il legame fra quota del grafo e quota della ricetta
|
||||
|
||||
`recipe_subtasks` prende **`vision_output`** (String, nullable): la coppia
|
||||
«identificativo dello strumento, nome della sua uscita».
|
||||
|
||||
È la stessa forma di legame che `vs-task` usa internamente, e per la stessa
|
||||
ragione dichiarata nel suo README: *«Il legame è un ID, non un puntatore»* — un
|
||||
riferimento in memoria non si scrive su disco.
|
||||
|
||||
Tutto il resto della subtask — nominale, UTL, UWL, LWL, LTL, unità — resta com'è
|
||||
e continua a significare quello che significa oggi.
|
||||
|
||||
### Le misure
|
||||
|
||||
`input_method` passa da `("usb_caliper", "manual")` a
|
||||
`("usb_caliper", "manual", "camera")`.
|
||||
|
||||
Il contorno della visione **non** va su `measurements`. Quella tabella la leggono
|
||||
la statistica e l'export a ogni giro, e appesantirla la rovina. Ma soprattutto
|
||||
sarebbe sbagliato di modello: una sola acquisizione produce N quote, e immagine,
|
||||
overlay, device e versione del motore sono gli stessi per tutte.
|
||||
|
||||
Nasce quindi **`vision_results`**, una riga per **esecuzione**:
|
||||
|
||||
| Campo | Perché c'è |
|
||||
|---|---|
|
||||
| `image_path`, `overlay_path` | l'immagine, e quella con sopra le zone dove ha guardato |
|
||||
| `engine_version` | il commit di VisionSuite che ha prodotto i numeri |
|
||||
| `executed_on` | `server` o `station` |
|
||||
| `station_id`, `device_id` | da quale macchina e quale camera |
|
||||
| `calibration_snapshot` | la taratura in vigore in quel momento, **copiata** |
|
||||
| `graph_snapshot` | il grafo effettivamente eseguito, **copiato** |
|
||||
| `duration_ms`, `executed_at` | quanto è costata, quando |
|
||||
|
||||
Su `measurements` una sola colonna nuova: `vision_result_id`, FK nullable.
|
||||
|
||||
Calibrazione e grafo si copiano invece di essere referenziati per la stessa
|
||||
ragione: una taratura rifatta sei mesi dopo non deve riscrivere il significato di
|
||||
una misura già presa.
|
||||
|
||||
### Il registro dei device
|
||||
|
||||
**`station_devices`**: `station_id`, `code` (l'identificativo locale), `kind`
|
||||
(`camera` oggi, `light` domani), `label`, `capabilities` (JSON), `calibration`
|
||||
(JSON) con `calibration_taken_at`, `last_seen_at`.
|
||||
|
||||
Si popola dall'agente. Il server lo riceve e non lo modifica.
|
||||
|
||||
### Le immagini di riferimento
|
||||
|
||||
**`vision_reference_images`**: `task_id`, `path`, e la provenienza per intero —
|
||||
`station_id`, `device_id`, snapshot della calibrazione, `engine_version`,
|
||||
`acquired_at`, una nota — più `expected_json` per l'atteso della prova di non
|
||||
regressione.
|
||||
|
||||
La provenienza non è rimandabile. Senza, si riesegue il grafo mesi dopo, escono
|
||||
numeri diversi, e non c'è modo di distinguere un motore aggiornato da un'ottica
|
||||
spostata.
|
||||
|
||||
### Migrazione 011
|
||||
|
||||
Cinque cose: `vision_json` su `recipe_tasks`, `vision_output` su
|
||||
`recipe_subtasks`, `camera` in `input_method_enum`, `vision_result_id` su
|
||||
`measurements`, e le tre tabelle nuove.
|
||||
|
||||
Più una sesta, **da fare adesso o mai**: `task_type_enum` contiene `xf_compare`,
|
||||
refuso per `dxf_compare`. Oggi nessuna riga usa quel valore e correggerlo costa
|
||||
una riga. Dal primo task salvato in poi diventa una migrazione di dati, e il
|
||||
refuso finisce nelle API pubbliche.
|
||||
|
||||
Come le migrazioni 008 e 010, va scritta in *batch mode* per la compatibilità
|
||||
SQLite dei test e verificata in resa MySQL con `alembic upgrade X:Y --sql` prima
|
||||
del deploy: è la procedura che il progetto si è già dato.
|
||||
|
||||
## API
|
||||
|
||||
L'agente apre lui la connessione. **Il server non bussa mai a un PC**, e questo
|
||||
evita di dover aprire porte sulle macchine di reparto — che è materia della D-4.
|
||||
|
||||
| Verso | Endpoint | Cosa fa |
|
||||
|---|---|---|
|
||||
| agente → server | `WS /api/stations/agent` | si autentica con la chiave di stazione, dichiara i device, resta in ascolto |
|
||||
| server → agente | comando `acquire` | «scatta sul device X»; l'agente carica con `POST /api/vision/frames` |
|
||||
| server → agente | comando `execute` | esecuzione in locale: manda il grafo, riceve le uscite |
|
||||
| client → server | `POST /api/vision/execute` | esegui il task per la produzione in corso; **il server sceglie dove** |
|
||||
| client → server | `POST /api/vision/preview` | authoring: esegui su una sorgente qualsiasi, non salvare nulla |
|
||||
| client → server | `GET /api/stations/{code}/devices` | l'elenco per il Maker |
|
||||
| client → server | `POST /api/vision/reference-images` | salva un fotogramma come riferimento |
|
||||
| server → worker | `POST /run` | interno, mai esposto: immagine e grafo → uscite e overlay |
|
||||
|
||||
Un solo endpoint decide dove si esegue, ed è `execute`. Il client non sa e non
|
||||
deve sapere se ha misurato il server o la stazione: chiede una misura e riceve un
|
||||
esito.
|
||||
|
||||
## Errori e casi limite
|
||||
|
||||
| Caso | Comportamento |
|
||||
|---|---|
|
||||
| Versione del motore diversa fra stazione e server | la stazione si rifiuta di misurare e lo dichiara. Mai produrre numeri con un motore non concorde |
|
||||
| Agente non raggiungibile | il task di visione non è eseguibile e lo dice. Da decidere con il cliente se la stazione debba consentire il ripiego sul calibro |
|
||||
| Server non raggiungibile, stazione con motore | la stazione esegue in locale e lo segna in `executed_on` |
|
||||
| Device dichiarato dalla ricetta ma offline | la ricetta resta valida, il task spiega perché non può girare |
|
||||
| Il grafo non produce un'uscita attesa da una subtask | la misura non si salva, l'errore nomina l'uscita mancante |
|
||||
| Elaborazione in crash o oltre il tempo | il worker isola il guasto, l'API resta in piedi, il task riporta l'errore |
|
||||
| Device senza calibrazione | le quote in millimetri non si producono. Mai convertire con un fattore implicito |
|
||||
|
||||
## Prove
|
||||
|
||||
Ai tre file di test che il progetto ha già e che leggono i sorgenti invece di
|
||||
renderizzare — `test_offline.py`, `test_layout_shell.py`,
|
||||
`test_template_js_syntax.py` — se ne aggiunge la stessa specie:
|
||||
|
||||
- **una prova che il server non importi VisionSuite.** È il confine su cui poggia
|
||||
la decisione 4, e senza qualcuno che lo controlli si perde in silenzio
|
||||
- **una prova che il commit del sottomodulo dichiarato coincida** con quello che
|
||||
server e agente montano
|
||||
|
||||
Per il resto, il runner si prova su immagini di riferimento salvate — che è
|
||||
precisamente ciò per cui l'astrazione della sorgente esiste. Nessuna prova
|
||||
richiede una camera collegata.
|
||||
|
||||
## Quello che questo design non risolve
|
||||
|
||||
- **Il dimensionamento del server.** Il pattern matching è pesante e, con
|
||||
l'esecuzione lato server come default, la CPU è contesa fra tutte le stazioni.
|
||||
Va aggiunto alla **D-4**, che finora chiedeva solo quale macchina e quanto
|
||||
disco: ora chiede anche quanti core e quanta RAM.
|
||||
- **La conservazione delle immagini.** Due politiche distinte, entrambe da
|
||||
portare in D-4: le immagini di riferimento sono poche e vivono quanto la
|
||||
ricetta; quelle di produzione sono una per pezzo e crescono senza limite, e
|
||||
vogliono una scadenza o una regola del tipo «si conservano solo quelle dei
|
||||
pezzi fuori tolleranza».
|
||||
- **Che cosa fa la stazione se la rete cade** e non è configurata per elaborare
|
||||
in locale: si blocca, o consente il calibro e segna il task come non
|
||||
eseguibile? È una domanda per il cliente.
|
||||
- **L'impacchettamento dell'agente.** Le macchine possono essere Linux o Windows
|
||||
e i driver possono stare sulla macchina o nel container. La decisione è
|
||||
volutamente tardiva; il design la rende tale tenendo l'agente sottile e neutro.
|
||||
|
||||
## Correzioni imposte dall'esecuzione
|
||||
|
||||
Il piano derivato da questo documento è stato eseguito il 16/08/2026. Sei cose
|
||||
che qui erano scritte male o taciute, corrette dai fatti.
|
||||
|
||||
### Il gate del fuori tolleranza vale per l'acquisizione, non per la quota
|
||||
|
||||
La decisione 7 e il testo del punto 5 parlano di «la quota successiva». Quella
|
||||
regola era stata pensata per la misura **manuale e sequenziale**, dove esiste
|
||||
davvero un momento successivo in cui l'operatore potrebbe tirare avanti con una
|
||||
lettura cattiva irrisolta.
|
||||
|
||||
Una camera produce tutte le N quote **nello stesso istante**. Dentro
|
||||
un'acquisizione non esiste un «tirare avanti», quindi lì non c'è nulla da
|
||||
presidiare — e applicare la regola sequenziale a un evento simultaneo produceva
|
||||
un blocco permanente: la lettura fuori tolleranza non veniva mai registrata,
|
||||
quindi nessuno poteva autorizzarla, e rifare la stessa foto ripeteva l'errore.
|
||||
|
||||
Il gate si valuta **una volta per acquisizione**: se la misura pendente è fra le
|
||||
quote che questa acquisizione riprodurrà non blocca — riacquisire *è* rimisurare
|
||||
quelle quote, che questo documento già dichiara la via d'uscita che non richiede
|
||||
il capoturno. Fra due acquisizioni, e alla chiusura del ciclo, blocca come prima.
|
||||
|
||||
### Il repository è deliberatamente bi-ambiente
|
||||
|
||||
`vs-task`, `vs-measure` e `vs-pm2d` dichiarano `requires-python >= 3.13`;
|
||||
`vs-core` si ferma a 3.10. Backend e frontend restano su **3.11**, l'albero della
|
||||
visione gira su **3.13**, e l'extra `vision` porta i marker d'ambiente che
|
||||
tengono separate le due risoluzioni.
|
||||
|
||||
Questo rafforza la decisione 4 più di come era argomentata: con VisionSuite dentro
|
||||
il server FastAPI, questa scoperta avrebbe costretto l'intero backend a 3.13.
|
||||
|
||||
### La versione del motore si timbra al build
|
||||
|
||||
`vendor/visionsuite/.git` è un file che rimanda a `../../.git/modules/...`, che
|
||||
nel contesto di build non esiste: interrogare git dentro il container non
|
||||
funziona. `engine_version()` legge `VISION_ENGINE_VERSION` dall'ambiente, ripiega
|
||||
su git nei checkout di sviluppo, e **alza un errore** se non risolve né l'uno né
|
||||
l'altro. Il build fallisce se la variabile manca.
|
||||
|
||||
### La divisione degli extra compra esplicitezza, non peso
|
||||
|
||||
Gli extra sono due — `vision` per il runner, `vision-worker` per il servizio web
|
||||
— ed è la forma giusta. Ma la motivazione che sembrava ovvia è falsa: `vs-pm2d`
|
||||
dichiara **di suo** `fastapi`, `uvicorn`, `python-multipart` e `pillow`, quindi
|
||||
un agente di stazione che fa pattern matching se li porterà dietro comunque. La
|
||||
correzione vera è a monte — una libreria non dovrebbe dipendere da un server web
|
||||
— e va aggiunta alle verifiche su VisionSuite qui sotto.
|
||||
|
||||
### L'immagine di produzione non viene ancora conservata
|
||||
|
||||
`vision_results.image_path` e `overlay_path` migrano e restano `NULL`: nulla le
|
||||
scrive. Il flusso descritto in questo documento si chiude con «il client mostra
|
||||
esito e immagine», e finché quelle colonne sono vuote una misura contestata non
|
||||
ha prova materiale. Consegna nominata del piano 1b, insieme all'overlay.
|
||||
|
||||
### Prima di applicare le migrazioni in produzione
|
||||
|
||||
La 011 rinomina un valore di enum su una tabella in esercizio. Va contato prima
|
||||
che nessuna riga usi `xf_compare`: in strict mode una riga sopravvissuta fa
|
||||
fallire l'`ALTER` a metà deploy, fuori strict mode diventa `''` in silenzio.
|
||||
Attenzione anche alla finestra: il `MODIFY` su `recipe_tasks` è una rinomina di
|
||||
valore e userà `ALGORITHM=COPY`, mentre quello su `measurements.input_method`
|
||||
aggiunge in coda e dovrebbe restare `INPLACE` — da confermare sulle dimensioni
|
||||
reali, perché `measurements` è la tabella grossa.
|
||||
|
||||
## Da verificare prima di cominciare
|
||||
|
||||
VisionSuite è stato lavorato fino al 15/08/2026 e il suo ultimo commit è
|
||||
*«Rassegna delle firme pubbliche prima che si congelino»*: le API si stanno
|
||||
stabilizzando proprio ora. Prima di fissare il commit del sottomodulo:
|
||||
|
||||
- **il suo README è indietro rispetto al codice.** Dichiara `vs-measure` «da
|
||||
scrivere» quando invece esiste, con circa 4.865 righe e tredici file di test, e
|
||||
dichiara `vs-dxf` non avviabile quando l'import di `common.calibration` non c'è
|
||||
più. Va riletto lo stato reale, non quello documentato.
|
||||
- **il test di calibrazione dato per rosso** — la focale recuperata a 373 contro
|
||||
456 attesi, con tolleranza al 5% — va verificato sullo stato attuale. Il README
|
||||
di VisionSuite avverte di guardarlo *prima* di costruirci sopra misure in
|
||||
millimetri, che è esattamente ciò che questo lavoro fa. Le calibrazioni sono
|
||||
state toccate il 13/08 e lo stato potrebbe essere cambiato.
|
||||
- **il peso dell'ambiente.** `vs-dxf` dipende da PyTorch e l'installazione
|
||||
completa arriva a circa 5 GB. È la ragione per cui il worker è un container a
|
||||
parte, e va confermato che il solo sottoinsieme necessario pesi meno.
|
||||
+33
-1
@@ -53,6 +53,32 @@ dev = [
|
||||
"coverage>=7.0.0",
|
||||
]
|
||||
|
||||
# Vision runner (VisionSuite submodule): what src/vision/runner.py needs to
|
||||
# execute a graph. vs-task/vs-measure/vs-pm2d require Python >=3.13 upstream;
|
||||
# marker keeps the base 3.11 resolution untouched. numpy is declared
|
||||
# explicitly - runner.py imports it directly, and it must not depend on
|
||||
# being pulled in as a side effect of vs-pm2d's own dependencies.
|
||||
vision = [
|
||||
"visionsuite; python_version>='3.13'",
|
||||
"vs-task; python_version>='3.13'",
|
||||
"vs-measure; python_version>='3.13'",
|
||||
"vs-pm2d; python_version>='3.13'",
|
||||
"numpy; python_version>='3.13'",
|
||||
]
|
||||
|
||||
# Vision worker (src/vision_worker/): the FastAPI shell around the runner,
|
||||
# on top of `vision`. Kept separate on purpose (Ruling R9) - VisionSuite's
|
||||
# whole structure exists so a consumer installs only what it needs, and a
|
||||
# later station agent embeds the same runner without being a web service.
|
||||
# It must not drag fastapi/uvicorn onto shop-floor PCs.
|
||||
vision-worker = [
|
||||
"tiemeasureflow[vision]",
|
||||
"fastapi>=0.110.0; python_version>='3.13'",
|
||||
"uvicorn[standard]>=0.30.0; python_version>='3.13'",
|
||||
"pillow>=10.0.0; python_version>='3.13'",
|
||||
"python-multipart>=0.0.6; python_version>='3.13'",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
# Backend
|
||||
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
||||
@@ -70,6 +96,12 @@ packages = ["src/backend", "src/frontend"]
|
||||
# Pin the resolver to the deps we declared; reproducible builds.
|
||||
package = false
|
||||
|
||||
[tool.uv.sources]
|
||||
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
|
||||
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
|
||||
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
|
||||
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests"]
|
||||
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests", "src/vision/tests", "src/vision_worker/tests"]
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Seed the recipes used to accept points 2, 3, 5, 8, 9 and 11 on a real screen.
|
||||
|
||||
The tests prove the rules hold; this puts them in front of an operator. Two
|
||||
recipes, because they need opposite settings:
|
||||
|
||||
COLLAUDO-A typing allowed, lot compulsory, two measurement tasks with
|
||||
documental tasks before, between and after them, interval of two
|
||||
minutes. This is the one to drive: the measurement loop, the
|
||||
bidirectional countdown, the out-of-tolerance gate, the cycle that
|
||||
closes only on the last measurement task.
|
||||
|
||||
COLLAUDO-B caliper only, lot and serial compulsory. This one is to look at:
|
||||
the keypad is not drawn, and Avvia does not start until both
|
||||
traceability fields are filled in.
|
||||
|
||||
Run it inside the server container, where src.backend and the database settings
|
||||
are already in place:
|
||||
|
||||
docker compose cp scripts/seed_collaudo.py server:/tmp/seed_collaudo.py
|
||||
docker compose exec server uv run python /tmp/seed_collaudo.py --station ST-DEFAULT
|
||||
|
||||
Re-running finds the recipes already there and stops. --replace deletes them
|
||||
first, along with anything measured against them - which is what you want between
|
||||
one acceptance session and the next, and never what you want by accident.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
from src.backend.database import async_session_factory # noqa: E402
|
||||
from src.backend.models.orm.measurement import Measurement # noqa: E402
|
||||
from src.backend.models.orm.production import ( # noqa: E402
|
||||
ProductionEvent, ProductionRun,
|
||||
)
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
|
||||
from src.backend.models.orm.station import ( # noqa: E402
|
||||
Station, StationRecipeAssignment,
|
||||
)
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
|
||||
from src.backend.models.orm.user import User # noqa: E402
|
||||
from src.backend.services import auth_service # noqa: E402
|
||||
|
||||
CODES = ("COLLAUDO-A", "COLLAUDO-B")
|
||||
|
||||
# Limits chosen so every outcome is one deliberate keystroke away:
|
||||
# 10.00 -> conforme 10.30 -> attenzione (dentro tolleranza)
|
||||
# 12.00 -> fuori tolleranza, serve il capoturno
|
||||
QUOTES_A1 = [
|
||||
dict(marker_number=1, description="Diametro esterno",
|
||||
nominal=10.0, ltl=9.5, lwl=9.8, uwl=10.2, utl=10.5, unit="mm"),
|
||||
dict(marker_number=2, description="Spessore parete",
|
||||
nominal=25.0, ltl=24.5, lwl=24.8, uwl=25.2, utl=25.5, unit="mm"),
|
||||
]
|
||||
QUOTES_A2 = [
|
||||
dict(marker_number=3, description="Lunghezza totale",
|
||||
nominal=100.0, ltl=99.0, lwl=99.5, uwl=100.5, utl=101.0, unit="mm"),
|
||||
]
|
||||
QUOTES_B = [
|
||||
dict(marker_number=1, description="Diametro con calibro",
|
||||
nominal=10.0, ltl=9.5, lwl=9.8, uwl=10.2, utl=10.5, unit="mm"),
|
||||
]
|
||||
|
||||
# A description with a blank line and a bold phrase: point 11 is visible or it is
|
||||
# not, and this is where you look.
|
||||
NOTA_INIZIALE = (
|
||||
"Prendere il pezzo dalla cassetta **a sinistra** della postazione.\n"
|
||||
"Pulire la superficie prima di misurare.\n\n"
|
||||
"**Attenzione**: non misurare pezzi ancora caldi."
|
||||
)
|
||||
NOTA_INTERMEDIA = (
|
||||
"Girare il pezzo di **90 gradi** prima della seconda misura.\n"
|
||||
"Se il pezzo non appoggia in piano, ripetere il posizionamento."
|
||||
)
|
||||
NOTA_FINALE = (
|
||||
"Riporre il pezzo nella cassetta **a destra**.\n"
|
||||
"Segnalare al capoturno qualsiasi quota fuori tolleranza."
|
||||
)
|
||||
|
||||
|
||||
async def _find_recipes(session, codes):
|
||||
result = await session.execute(select(Recipe).where(Recipe.code.in_(codes)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _delete_recipes(session, recipes):
|
||||
"""Remove the collaudo recipes and everything measured against them."""
|
||||
for recipe in recipes:
|
||||
versions = (await session.execute(
|
||||
select(RecipeVersion.id).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().all()
|
||||
runs = (await session.execute(
|
||||
select(ProductionRun.id).where(ProductionRun.recipe_id == recipe.id)
|
||||
)).scalars().all()
|
||||
|
||||
if versions:
|
||||
await session.execute(
|
||||
delete(Measurement).where(Measurement.version_id.in_(versions))
|
||||
)
|
||||
if runs:
|
||||
await session.execute(
|
||||
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
|
||||
)
|
||||
await session.execute(
|
||||
delete(ProductionRun).where(ProductionRun.id.in_(runs))
|
||||
)
|
||||
await session.execute(
|
||||
delete(StationRecipeAssignment).where(
|
||||
StationRecipeAssignment.recipe_id == recipe.id
|
||||
)
|
||||
)
|
||||
# Versions, tasks and subtasks go with the recipe through the ORM cascade.
|
||||
await session.delete(recipe)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _add_task(session, version_id, order_index, title, task_type,
|
||||
directive=None, description=None, quotes=()):
|
||||
task = RecipeTask(
|
||||
version_id=version_id,
|
||||
order_index=order_index,
|
||||
title=title,
|
||||
task_type=task_type,
|
||||
directive=directive,
|
||||
description=description,
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
for quote in quotes:
|
||||
session.add(RecipeSubtask(task_id=task.id, **quote))
|
||||
await session.flush()
|
||||
return task
|
||||
|
||||
|
||||
async def _create_recipe(session, user_id, *, code, name, description,
|
||||
interval, requires_lot, requires_serial,
|
||||
allow_manual_input):
|
||||
recipe = Recipe(
|
||||
code=code,
|
||||
name=name,
|
||||
description=description,
|
||||
created_by=user_id,
|
||||
active=True,
|
||||
measurement_interval_minutes=interval,
|
||||
requires_lot=requires_lot,
|
||||
requires_serial=requires_serial,
|
||||
allow_manual_input=allow_manual_input,
|
||||
)
|
||||
session.add(recipe)
|
||||
await session.flush()
|
||||
|
||||
version = RecipeVersion(
|
||||
recipe_id=recipe.id,
|
||||
version_number=1,
|
||||
is_current=True,
|
||||
created_by=user_id,
|
||||
change_notes="Ricetta di collaudo",
|
||||
)
|
||||
session.add(version)
|
||||
await session.flush()
|
||||
return recipe, version
|
||||
|
||||
|
||||
async def _assign(session, station, recipe, user_id):
|
||||
session.add(StationRecipeAssignment(
|
||||
station_id=station.id, recipe_id=recipe.id, assigned_by=user_id,
|
||||
))
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _ensure_supervisor(session, username, password):
|
||||
"""A capoturno with the role, not an admin standing in for one.
|
||||
|
||||
An admin is accepted by the authorisation check, but the acceptance session
|
||||
should exercise the role the shop floor will actually use.
|
||||
"""
|
||||
existing = (await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
|
||||
user = await auth_service.create_user(
|
||||
session,
|
||||
username=username,
|
||||
password=password,
|
||||
display_name="Capo Turno (collaudo)",
|
||||
roles=["Supervisor", "MeasurementTec"],
|
||||
)
|
||||
await session.flush()
|
||||
return user, True
|
||||
|
||||
|
||||
async def seed(station_code: str, replace: bool, supervisor_password: str) -> int:
|
||||
async with async_session_factory() as session:
|
||||
owner = (await session.execute(
|
||||
select(User).where(User.is_admin == True) # noqa: E712
|
||||
)).scalars().first()
|
||||
if owner is None:
|
||||
print("Nessun utente amministratore: impossibile intestare le ricette.")
|
||||
return 2
|
||||
|
||||
station = (await session.execute(
|
||||
select(Station).where(Station.code == station_code)
|
||||
)).scalar_one_or_none()
|
||||
if station is None:
|
||||
print(f"Stazione '{station_code}' inesistente.")
|
||||
return 2
|
||||
|
||||
existing = await _find_recipes(session, CODES)
|
||||
if existing and not replace:
|
||||
found = ", ".join(r.code for r in existing)
|
||||
print(f"Ricette di collaudo gia' presenti ({found}).")
|
||||
print("Usa --replace per rifarle da zero (cancella anche le misure).")
|
||||
return 1
|
||||
if existing:
|
||||
await _delete_recipes(session, existing)
|
||||
print(f"Rimosse: {', '.join(r.code for r in existing)}")
|
||||
|
||||
# ---- COLLAUDO-A: the one to drive ----
|
||||
recipe_a, version_a = await _create_recipe(
|
||||
session, owner.id,
|
||||
code="COLLAUDO-A",
|
||||
name="Collaudo — ciclo di misura",
|
||||
description=(
|
||||
"Due task di misura con task documentali intorno. "
|
||||
"Lotto obbligatorio, inserimento manuale consentito, "
|
||||
"intervallo di 2 minuti."
|
||||
),
|
||||
interval=2,
|
||||
requires_lot=True,
|
||||
requires_serial=False,
|
||||
allow_manual_input=True,
|
||||
)
|
||||
await _add_task(
|
||||
session, version_a.id, 0, "Preparazione del pezzo", "note",
|
||||
directive="Leggere prima di iniziare",
|
||||
description=NOTA_INIZIALE,
|
||||
)
|
||||
await _add_task(
|
||||
session, version_a.id, 1, "Misura 1 — diametro e spessore", "measure",
|
||||
directive="Misurare le due quote in sequenza",
|
||||
description="Quota **1** e quota **2**.\nUsare il calibro o il tastierino.",
|
||||
quotes=QUOTES_A1,
|
||||
)
|
||||
await _add_task(
|
||||
session, version_a.id, 2, "Riposizionamento", "note",
|
||||
directive="Fra la prima e la seconda misura",
|
||||
description=NOTA_INTERMEDIA,
|
||||
)
|
||||
await _add_task(
|
||||
session, version_a.id, 3, "Misura 2 — lunghezza", "measure",
|
||||
directive="Ultimo task di misura: chiude il ciclo",
|
||||
description="Alla conferma di questa quota **riparte l'intervallo**.",
|
||||
quotes=QUOTES_A2,
|
||||
)
|
||||
await _add_task(
|
||||
session, version_a.id, 4, "Chiusura", "note",
|
||||
directive="Dopo le misure",
|
||||
description=NOTA_FINALE,
|
||||
)
|
||||
await _assign(session, station, recipe_a, owner.id)
|
||||
|
||||
# ---- COLLAUDO-B: the one to look at ----
|
||||
recipe_b, version_b = await _create_recipe(
|
||||
session, owner.id,
|
||||
code="COLLAUDO-B",
|
||||
name="Collaudo — solo calibro",
|
||||
description=(
|
||||
"Lotto e seriale obbligatori, inserimento manuale vietato: "
|
||||
"il tastierino non compare e Avvia non parte finche' mancano i dati."
|
||||
),
|
||||
interval=5,
|
||||
requires_lot=True,
|
||||
requires_serial=True,
|
||||
allow_manual_input=False,
|
||||
)
|
||||
await _add_task(
|
||||
session, version_b.id, 0, "Misura con calibro", "measure",
|
||||
directive="Il valore deve arrivare dal calibro",
|
||||
description="Nessun tastierino: la ricetta **non ammette valori digitati**.",
|
||||
quotes=QUOTES_B,
|
||||
)
|
||||
await _assign(session, station, recipe_b, owner.id)
|
||||
|
||||
supervisor, created = await _ensure_supervisor(
|
||||
session, "capoturno", supervisor_password,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
print(f"Ricette create e assegnate alla stazione {station_code}:")
|
||||
print(" COLLAUDO-A ciclo di misura, lotto obbligatorio, intervallo 2 min")
|
||||
print(" COLLAUDO-B solo calibro, lotto e seriale obbligatori")
|
||||
if created:
|
||||
print(f"Capoturno creato: {supervisor.username} / {supervisor_password}")
|
||||
print(" cambiare la password prima di qualunque uso reale.")
|
||||
else:
|
||||
print(f"Capoturno gia' presente: {supervisor.username} (password invariata)")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--station", default="ST-DEFAULT",
|
||||
help="codice della stazione a cui assegnare le ricette")
|
||||
parser.add_argument("--replace", action="store_true",
|
||||
help="cancella le ricette di collaudo esistenti e le misure fatte su di esse")
|
||||
parser.add_argument("--supervisor-password", default="Collaudo2026!",
|
||||
help="password del capoturno, se va creato")
|
||||
args = parser.parse_args()
|
||||
return asyncio.run(
|
||||
seed(args.station, args.replace, args.supervisor_password)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -19,11 +19,14 @@ 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 (
|
||||
MeasurementAuthorisation,
|
||||
MeasurementBatchCreate,
|
||||
MeasurementCreate,
|
||||
MeasurementListResponse,
|
||||
MeasurementResponse,
|
||||
TaskProgressListResponse,
|
||||
)
|
||||
from src.backend.services import auth_service, measurement_service
|
||||
from src.backend.services.measurement_service import save_measurement
|
||||
|
||||
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||
@@ -57,6 +60,78 @@ async def create_measurement(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pending-authorisation", response_model=MeasurementResponse | None)
|
||||
async def get_pending_authorisation(
|
||||
version_id: int = Query(..., gt=0),
|
||||
production_run_id: int | None = Query(None, gt=0),
|
||||
user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""The out-of-tolerance measurement holding this operator up, or null.
|
||||
|
||||
Asked on load, so that reloading the page is not a way past the gate: the
|
||||
screen finds the same block waiting for it that the server enforces.
|
||||
"""
|
||||
blocking = await measurement_service.pending_authorisation(
|
||||
db, version_id, user.id, production_run_id,
|
||||
)
|
||||
return MeasurementResponse.model_validate(blocking) if blocking else None
|
||||
|
||||
|
||||
@router.get("/task-progress", response_model=TaskProgressListResponse)
|
||||
async def get_task_progress(
|
||||
version_id: int = Query(..., gt=0),
|
||||
production_run_id: int | None = Query(None, gt=0),
|
||||
user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""How many quotes of each measurement task are already taken.
|
||||
|
||||
The task list is where an operator picks up work they left behind, and until
|
||||
now it showed a task begun and a task never touched identically.
|
||||
"""
|
||||
tasks = await measurement_service.task_progress(
|
||||
db, version_id, user.id, production_run_id,
|
||||
)
|
||||
return TaskProgressListResponse(tasks=tasks)
|
||||
|
||||
|
||||
@router.post("/{measurement_id}/authorise", response_model=MeasurementResponse)
|
||||
async def authorise_measurement(
|
||||
measurement_id: int,
|
||||
action: MeasurementAuthorisation,
|
||||
user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Let an out-of-tolerance value stand, on the capoturno's name.
|
||||
|
||||
Credentials go to this endpoint rather than to a separate check because the
|
||||
approval has to end up attached to the measurement. A check whose answer is
|
||||
thrown away is what this point had before: a modal that closed.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Measurement).where(Measurement.id == measurement_id)
|
||||
)
|
||||
measurement = result.scalar_one_or_none()
|
||||
if measurement is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Measurement not found",
|
||||
)
|
||||
if measurement.pass_fail != "fail":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="This measurement is within tolerance: there is nothing to authorise",
|
||||
)
|
||||
|
||||
supervisor = await auth_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
)
|
||||
measurement = await measurement_service.authorise_measurement(
|
||||
db, measurement, supervisor,
|
||||
)
|
||||
return MeasurementResponse.model_validate(measurement)
|
||||
|
||||
|
||||
@router.post("/batch", response_model=list[MeasurementResponse])
|
||||
async def create_measurement_batch(
|
||||
data: MeasurementBatchCreate,
|
||||
|
||||
@@ -20,7 +20,7 @@ from src.backend.models.api.production import (
|
||||
)
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import production_service
|
||||
from src.backend.services import auth_service, production_service
|
||||
|
||||
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||
|
||||
@@ -124,7 +124,7 @@ async def pause_production_run(
|
||||
):
|
||||
"""Fermo linea - requires a supervisor."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
supervisor = await production_service.authorise_supervisor(
|
||||
supervisor = await auth_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
)
|
||||
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
||||
@@ -140,7 +140,7 @@ async def resume_production_run(
|
||||
):
|
||||
"""Restart a stopped line - requires a supervisor."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
supervisor = await production_service.authorise_supervisor(
|
||||
supervisor = await auth_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
)
|
||||
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
||||
@@ -156,7 +156,7 @@ async def close_production_run(
|
||||
):
|
||||
"""Fine produzione - requires a supervisor. Stops the timer for good."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
supervisor = await production_service.authorise_supervisor(
|
||||
supervisor = await auth_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
)
|
||||
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
||||
|
||||
@@ -179,6 +179,7 @@ async def create_task(
|
||||
file_path=data.file_path,
|
||||
file_type=data.file_type,
|
||||
annotations_json=data.annotations_json,
|
||||
vision_json=data.vision_json,
|
||||
)
|
||||
db.add(new_task)
|
||||
await db.flush()
|
||||
@@ -197,6 +198,7 @@ async def create_task(
|
||||
ltl=sub_data.ltl,
|
||||
unit=sub_data.unit,
|
||||
image_path=sub_data.image_path,
|
||||
vision_output=sub_data.vision_output,
|
||||
)
|
||||
db.add(sub)
|
||||
|
||||
@@ -340,6 +342,7 @@ async def create_subtask(
|
||||
ltl=data.ltl,
|
||||
unit=data.unit,
|
||||
image_path=data.image_path,
|
||||
vision_output=data.vision_output,
|
||||
)
|
||||
db.add(subtask)
|
||||
await db.flush()
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""The client asks for a measurement and gets an outcome.
|
||||
|
||||
It does not know, and must not know, whether the server or the station computed
|
||||
the numbers: that is what lets a station's configuration change without touching
|
||||
the frontend or the recipes.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
|
||||
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, validate_file_size
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.models.api.vision import VisionExecuteResponse, VisionMeasurementResult
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import vision_service
|
||||
|
||||
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
||||
|
||||
# A vision graph measures pixels, not a PDF: stricter than files.py's
|
||||
# ALLOWED_TYPES, which also accepts documents for technical drawings.
|
||||
_IMAGE_EXTENSIONS = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
|
||||
|
||||
async def _task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
|
||||
"""Shared by the endpoints of this router."""
|
||||
task = (await db.execute(
|
||||
select(RecipeTask).where(RecipeTask.id == task_id)
|
||||
)).scalar_one_or_none()
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
async def _read_image(image: UploadFile) -> bytes:
|
||||
"""Type checked from the header before reading, size checked once the body
|
||||
is in hand - the same two-step convention `files.py::upload_file` already
|
||||
uses, reused rather than reinvented here.
|
||||
"""
|
||||
if image.content_type not in ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
||||
)
|
||||
content = await image.read()
|
||||
if not validate_file_size(len(content)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"File size {len(content)} bytes exceeds maximum "
|
||||
f"{settings.max_upload_size_mb}MB"
|
||||
),
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
@router.post("/execute", response_model=VisionExecuteResponse)
|
||||
async def execute(
|
||||
task_id: int = Form(...),
|
||||
image: UploadFile = File(...),
|
||||
lot_number: str | None = Form(None),
|
||||
serial_number: str | None = Form(None),
|
||||
production_run_id: int | None = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_measurement_tec),
|
||||
) -> VisionExecuteResponse:
|
||||
task = await _task_or_404(db, task_id)
|
||||
|
||||
result, saved = await vision_service.execute_task(
|
||||
db,
|
||||
task,
|
||||
await _read_image(image),
|
||||
current_user.id,
|
||||
version_id=task.version_id,
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
production_run_id=production_run_id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return VisionExecuteResponse(
|
||||
vision_result_id=result.id,
|
||||
engine_version=result.engine_version,
|
||||
measurements=[
|
||||
VisionMeasurementResult(
|
||||
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
|
||||
)
|
||||
for m in saved
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# Composing a recipe and keeping a reference image are Maker operations, not
|
||||
# measuring: both endpoints below gate on `require_maker`, not the
|
||||
# `require_measurement_tec` that `execute` above uses.
|
||||
|
||||
|
||||
@router.post("/reference-images")
|
||||
async def add_reference_image(
|
||||
task_id: int = Form(...),
|
||||
image: UploadFile = File(...),
|
||||
note: str | None = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> dict:
|
||||
task = await _task_or_404(db, task_id)
|
||||
content = await _read_image(image)
|
||||
reference = await vision_service.save_reference_image(
|
||||
db, task.id, content, note=note,
|
||||
extension=_IMAGE_EXTENSIONS[image.content_type],
|
||||
)
|
||||
await db.commit()
|
||||
return {"id": reference.id, "path": reference.path, "note": reference.note}
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
async def preview(
|
||||
task_id: int = Form(...),
|
||||
image: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> dict:
|
||||
task = await _task_or_404(db, task_id)
|
||||
return await vision_service.preview(db, task, await _read_image(image))
|
||||
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
|
||||
openrouter_api_key: str | None = None
|
||||
openrouter_model: str = "anthropic/claude-sonnet-4"
|
||||
|
||||
# Vision worker (internal network only, reachable as `vision` in Compose)
|
||||
vision_worker_url: str = "http://vision:8100"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Async MySQL connection string."""
|
||||
|
||||
@@ -22,6 +22,7 @@ from src.backend.api.routers.statistics import router as statistics_router
|
||||
from src.backend.api.routers.setup import router as setup_router
|
||||
from src.backend.api.routers.stations import router as stations_router
|
||||
from src.backend.api.routers.production import router as production_router
|
||||
from src.backend.api.routers.vision import router as vision_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -75,6 +76,7 @@ app.include_router(reports_router)
|
||||
app.include_router(setup_router)
|
||||
app.include_router(stations_router)
|
||||
app.include_router(production_router)
|
||||
app.include_router(vision_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""traceability and manual input become rules of the recipe
|
||||
|
||||
Three settings that were nowhere: whether the lot and the serial are compulsory,
|
||||
and whether a value may be typed instead of read from the caliper. Until now lot
|
||||
and serial were optional everywhere and the keypad was always available, so a
|
||||
value that happens to be in tolerance could simply be entered by hand.
|
||||
|
||||
Backfill: the two traceability flags start false, which is exactly today's
|
||||
behaviour. Manual input starts *true* on the recipes that already exist - the
|
||||
column default is false, so recipes written from now on are caliper-only, but
|
||||
flipping the ones already in use would stop a running line at the next
|
||||
measurement. Turning them off is a decision for whoever owns the recipe, taken in
|
||||
the editor, not a side effect of an upgrade.
|
||||
|
||||
Revision ID: 009_recipe_rules
|
||||
Revises: 008_loop_events
|
||||
Create Date: 2026-07-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '009_recipe_rules'
|
||||
down_revision: Union[str, None] = '008_loop_events'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'recipes',
|
||||
sa.Column(
|
||||
'requires_lot', sa.Boolean(), nullable=False, server_default='0',
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'recipes',
|
||||
sa.Column(
|
||||
'requires_serial', sa.Boolean(), nullable=False, server_default='0',
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'recipes',
|
||||
sa.Column(
|
||||
'allow_manual_input', sa.Boolean(), nullable=False, server_default='0',
|
||||
),
|
||||
)
|
||||
# Recipes already in production keep the behaviour they were written under.
|
||||
op.execute("UPDATE recipes SET allow_manual_input = 1")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('recipes', 'allow_manual_input')
|
||||
op.drop_column('recipes', 'requires_serial')
|
||||
op.drop_column('recipes', 'requires_lot')
|
||||
@@ -0,0 +1,49 @@
|
||||
"""record who authorised an out-of-tolerance measurement
|
||||
|
||||
The supervisor gate existed on screen and nowhere else: the modal opened, the
|
||||
credentials were checked, the modal closed, and nothing was written down. Nothing
|
||||
depended on the answer either, so a value outside tolerance could be left behind
|
||||
by dismissing the modal.
|
||||
|
||||
These two columns are what the rule now stands on. A failed measurement with no
|
||||
supervisor on it is a measurement waiting for one, and while one is waiting the
|
||||
operator cannot move to the next quote.
|
||||
|
||||
Existing rows are left null. Backfilling an authorisation that never happened
|
||||
would be inventing an audit record; the fails already in the database belong to
|
||||
production runs that are over, and nothing is waiting on them.
|
||||
|
||||
Revision ID: 010_meas_authorisation
|
||||
Revises: 009_recipe_rules
|
||||
Create Date: 2026-07-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '010_meas_authorisation'
|
||||
down_revision: Union[str, None] = '009_recipe_rules'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('measurements') as batch:
|
||||
batch.add_column(sa.Column('supervisor_id', sa.Integer(), nullable=True))
|
||||
batch.add_column(sa.Column('authorised_at', sa.DateTime(), nullable=True))
|
||||
batch.create_foreign_key(
|
||||
'fk_measurements_supervisor_id', 'users', ['supervisor_id'], ['id'],
|
||||
)
|
||||
batch.create_index(
|
||||
'ix_measurements_supervisor_id', ['supervisor_id'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('measurements') as batch:
|
||||
batch.drop_index('ix_measurements_supervisor_id')
|
||||
batch.drop_constraint('fk_measurements_supervisor_id', type_='foreignkey')
|
||||
batch.drop_column('authorised_at')
|
||||
batch.drop_column('supervisor_id')
|
||||
@@ -0,0 +1,55 @@
|
||||
"""the vision graph lives on the task, and the quote names its output
|
||||
|
||||
`xf_compare` was a typo for `dxf_compare`. No row uses either value yet, so
|
||||
renaming costs one statement here; from the first saved task onwards it would
|
||||
be a data migration and the typo would already be in the public API.
|
||||
|
||||
Revision ID: 011_vision_graph
|
||||
Revises: 010_meas_authorisation
|
||||
Create Date: 2026-08-16
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '011_vision_graph'
|
||||
down_revision: Union[str, None] = '010_meas_authorisation'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
OLD_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
|
||||
NEW_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.add_column(sa.Column('vision_json', sa.JSON(), nullable=True))
|
||||
batch.alter_column(
|
||||
'task_type',
|
||||
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||
existing_nullable=False,
|
||||
existing_server_default='note',
|
||||
)
|
||||
|
||||
with op.batch_alter_table('recipe_subtasks') as batch:
|
||||
batch.add_column(
|
||||
sa.Column('vision_output', sa.String(length=120), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('recipe_subtasks') as batch:
|
||||
batch.drop_column('vision_output')
|
||||
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.alter_column(
|
||||
'task_type',
|
||||
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||
existing_nullable=False,
|
||||
existing_server_default='note',
|
||||
)
|
||||
batch.drop_column('vision_json')
|
||||
@@ -0,0 +1,79 @@
|
||||
"""one row per vision execution, and camera as an input method
|
||||
|
||||
The vision surroundings do not go on `measurements`: statistics and the export
|
||||
read that table on every pass. And it would be wrong modelling anyway - one
|
||||
acquisition produces N quotes, and image, overlay, device and engine version are
|
||||
the same for all of them.
|
||||
|
||||
Revision ID: 012_vision_results
|
||||
Revises: 011_vision_graph
|
||||
Create Date: 2026-08-16
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '012_vision_results'
|
||||
down_revision: Union[str, None] = '011_vision_graph'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
OLD_METHODS = ('usb_caliper', 'manual')
|
||||
NEW_METHODS = ('usb_caliper', 'manual', 'camera')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'vision_results',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('task_id', sa.Integer(),
|
||||
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
||||
sa.Column('image_path', sa.String(length=500), nullable=True),
|
||||
sa.Column('overlay_path', sa.String(length=500), nullable=True),
|
||||
sa.Column('engine_version', sa.String(length=64), nullable=False),
|
||||
sa.Column('executed_on',
|
||||
sa.Enum('server', 'station', name='vision_executed_on_enum'),
|
||||
nullable=False),
|
||||
sa.Column('station_id', sa.Integer(),
|
||||
sa.ForeignKey('stations.id'), nullable=True),
|
||||
sa.Column('device_code', sa.String(length=100), nullable=True),
|
||||
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
||||
sa.Column('graph_snapshot', sa.JSON(), nullable=False),
|
||||
sa.Column('duration_ms', sa.Integer(), nullable=True),
|
||||
sa.Column('executed_at', sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
with op.batch_alter_table('measurements') as batch:
|
||||
batch.alter_column(
|
||||
'input_method',
|
||||
existing_type=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
||||
type_=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
||||
existing_nullable=False,
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column('vision_result_id', sa.Integer(), nullable=True)
|
||||
)
|
||||
batch.create_foreign_key(
|
||||
'fk_measurements_vision_result',
|
||||
'vision_results', ['vision_result_id'], ['id'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('measurements') as batch:
|
||||
batch.drop_constraint('fk_measurements_vision_result',
|
||||
type_='foreignkey')
|
||||
batch.drop_column('vision_result_id')
|
||||
batch.alter_column(
|
||||
'input_method',
|
||||
existing_type=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
||||
type_=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
op.drop_table('vision_results')
|
||||
@@ -0,0 +1,49 @@
|
||||
"""reference images, with their provenance
|
||||
|
||||
A reference image without provenance is a trap: the graph is re-run months later,
|
||||
different numbers come out, and there is no way to tell an updated engine from a
|
||||
moved lens.
|
||||
|
||||
`expected_json` holds what the graph is supposed to produce on this image. It is
|
||||
what makes a regression run possible after a VisionSuite upgrade - the safety net
|
||||
that makes bumping the submodule sustainable with an engine on two hosts.
|
||||
|
||||
Revision ID: 013_reference_images
|
||||
Revises: 012_vision_results
|
||||
Create Date: 2026-08-16
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '013_reference_images'
|
||||
down_revision: Union[str, None] = '012_vision_results'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'vision_reference_images',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('task_id', sa.Integer(),
|
||||
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
||||
sa.Column('path', sa.String(length=500), nullable=False),
|
||||
sa.Column('station_id', sa.Integer(),
|
||||
sa.ForeignKey('stations.id'), nullable=True),
|
||||
sa.Column('device_code', sa.String(length=100), nullable=True),
|
||||
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
||||
sa.Column('engine_version', sa.String(length=64), nullable=True),
|
||||
sa.Column('note', sa.String(length=500), nullable=True),
|
||||
sa.Column('expected_json', sa.JSON(), nullable=True),
|
||||
sa.Column('acquired_at', sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('vision_reference_images')
|
||||
@@ -12,6 +12,11 @@ class MeasurementCreate(BaseModel):
|
||||
value: float
|
||||
lot_number: Optional[str] = Field(None, max_length=100)
|
||||
serial_number: Optional[str] = Field(None, max_length=100)
|
||||
# "camera" is deliberately absent from this pattern, even though the ORM
|
||||
# enum and the database allow it. Do not add it here: a camera
|
||||
# measurement must be produced by vision_service.execute_task, never by a
|
||||
# client posting JSON straight to this endpoint - there is no image, no
|
||||
# graph and no engine_version behind a value that arrived this way.
|
||||
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
||||
input_duration_ms: Optional[int] = Field(None, ge=0)
|
||||
# The production this belongs to, when one is open at the station.
|
||||
@@ -39,6 +44,10 @@ class MeasurementResponse(BaseModel):
|
||||
input_method: str
|
||||
input_duration_ms: Optional[int] = None
|
||||
production_run_id: Optional[int] = None
|
||||
# Who let this value stand, when it was out of tolerance. Null on a value in
|
||||
# tolerance - and on one that is not and is still waiting for an answer.
|
||||
supervisor_id: Optional[int] = None
|
||||
authorised_at: Optional[datetime] = None
|
||||
measured_at: datetime
|
||||
synced_to_csv: bool
|
||||
|
||||
@@ -52,6 +61,29 @@ class MeasurementListResponse(BaseModel):
|
||||
pages: int
|
||||
|
||||
|
||||
class MeasurementAuthorisation(BaseModel):
|
||||
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
||||
|
||||
supervisor_username: str = Field(..., min_length=1)
|
||||
supervisor_password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class TaskProgressResponse(BaseModel):
|
||||
"""How far one measurement task has got: quotes taken out of quotes expected."""
|
||||
|
||||
task_id: int
|
||||
quotes: int
|
||||
measured: int
|
||||
# empty (declared as measurement, no quotes yet) | none | partial | complete
|
||||
state: str
|
||||
|
||||
|
||||
class TaskProgressListResponse(BaseModel):
|
||||
"""Progress of every measurement task of a version."""
|
||||
|
||||
tasks: list[TaskProgressResponse]
|
||||
|
||||
|
||||
class MeasurementQuery(BaseModel):
|
||||
"""Schema for measurement query filters."""
|
||||
recipe_id: Optional[int] = None
|
||||
|
||||
@@ -15,6 +15,11 @@ class RecipeCreate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||
# Rules of the recipe: what the operator must supply, and how a value may be
|
||||
# entered. Manual input defaults to forbidden - the caliper is the instrument.
|
||||
requires_lot: bool = False
|
||||
requires_serial: bool = False
|
||||
allow_manual_input: bool = False
|
||||
# Optional task-level fields for the initial technical drawing
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
@@ -27,6 +32,9 @@ class RecipeUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||
requires_lot: Optional[bool] = None
|
||||
requires_serial: Optional[bool] = None
|
||||
allow_manual_input: Optional[bool] = None
|
||||
change_notes: Optional[str] = None
|
||||
# Task-level fields: saved to the first task of the new version
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
@@ -58,6 +66,11 @@ class RecipeResponse(BaseModel):
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = None
|
||||
measurement_interval_minutes: Optional[int] = None
|
||||
# The operator's screen reads these to know whether to ask for lot and serial
|
||||
# before starting, and whether to offer the keypad at all.
|
||||
requires_lot: bool = False
|
||||
requires_serial: bool = False
|
||||
allow_manual_input: bool = False
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
active: bool
|
||||
|
||||
@@ -53,6 +53,10 @@ class RecipeSummary(BaseModel):
|
||||
active: bool
|
||||
image_path: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
# The selection screen has to know before the operator starts: what a recipe
|
||||
# demands is the difference between an enabled Avvia and a disabled one.
|
||||
requires_lot: bool = False
|
||||
requires_serial: bool = False
|
||||
|
||||
|
||||
class StationWithRecipesResponse(StationResponse):
|
||||
|
||||
@@ -21,6 +21,7 @@ class SubtaskCreate(BaseModel):
|
||||
ltl: Optional[float] = None
|
||||
unit: str = Field("mm", max_length=20)
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
vision_output: Optional[str] = None
|
||||
|
||||
|
||||
class SubtaskUpdate(BaseModel):
|
||||
@@ -35,6 +36,7 @@ class SubtaskUpdate(BaseModel):
|
||||
ltl: Optional[float] = None
|
||||
unit: Optional[str] = Field(None, max_length=20)
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
vision_output: Optional[str] = None
|
||||
|
||||
|
||||
class SubtaskResponse(BaseModel):
|
||||
@@ -53,6 +55,7 @@ class SubtaskResponse(BaseModel):
|
||||
ltl: Optional[float] = None
|
||||
unit: str
|
||||
image_path: Optional[str] = None
|
||||
vision_output: Optional[str] = None
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
@@ -64,6 +67,7 @@ class TaskCreate(BaseModel):
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
subtasks: list[SubtaskCreate] = []
|
||||
|
||||
|
||||
@@ -76,6 +80,7 @@ class TaskUpdate(BaseModel):
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
@@ -93,6 +98,7 @@ class TaskResponse(BaseModel):
|
||||
file_path: Optional[str] = None
|
||||
file_type: Optional[str] = None
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
subtasks: list[SubtaskResponse] = []
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Pydantic schemas for the vision execution response."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class VisionMeasurementResult(BaseModel):
|
||||
"""One quote's outcome, as much of it as the client needs to show."""
|
||||
subtask_id: int
|
||||
value: float
|
||||
pass_fail: str
|
||||
|
||||
|
||||
class VisionExecuteResponse(BaseModel):
|
||||
"""What `POST /api/vision/execute` returns: the run, and every quote it filled."""
|
||||
vision_result_id: int
|
||||
engine_version: str
|
||||
measurements: list[VisionMeasurementResult]
|
||||
@@ -7,6 +7,7 @@ from src.backend.models.orm.access_log import AccessLog
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.models.orm.production import ProductionRun, ProductionEvent
|
||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -22,4 +23,6 @@ __all__ = [
|
||||
"StationRecipeAssignment",
|
||||
"ProductionRun",
|
||||
"ProductionEvent",
|
||||
"VisionResult",
|
||||
"VisionReferenceImage",
|
||||
]
|
||||
|
||||
@@ -38,11 +38,17 @@ class Measurement(Base):
|
||||
|
||||
# Input method
|
||||
input_method: Mapped[str] = mapped_column(
|
||||
Enum("usb_caliper", "manual", name="input_method_enum"),
|
||||
Enum("usb_caliper", "manual", "camera", name="input_method_enum"),
|
||||
nullable=False,
|
||||
default="manual",
|
||||
)
|
||||
|
||||
# The vision execution that produced this value, when there was one. Null for
|
||||
# everything taken by caliper or typed by hand.
|
||||
vision_result_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("vision_results.id"), nullable=True
|
||||
)
|
||||
|
||||
# Time spent by the operator entering this value (client-reported)
|
||||
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, nullable=True
|
||||
@@ -59,6 +65,16 @@ class Measurement(Base):
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Who let an out-of-tolerance value stand, and when. Null on a value that is
|
||||
# within tolerance, and null on one that is not and has not been authorised -
|
||||
# which is exactly the state that stops the operator moving on to the next
|
||||
# quote. Before this the supervisor's approval was a modal that closed: nothing
|
||||
# recorded it, and nothing depended on it.
|
||||
supervisor_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
authorised_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Timestamp
|
||||
measured_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now(), index=True
|
||||
|
||||
@@ -26,6 +26,22 @@ class Recipe(Base):
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
|
||||
|
||||
# Traceability, decided when the recipe is written rather than left to whoever
|
||||
# is at the machine: a measurement without the lot it belongs to cannot be
|
||||
# traced back afterwards, and tracing back is half of what the system is for.
|
||||
requires_lot: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
requires_serial: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
# Whether a value may be typed instead of read from the caliper. Forbidden by
|
||||
# default: without the rule, a value that happens to be in tolerance can simply
|
||||
# be entered by hand, which is the case this setting exists to prevent.
|
||||
allow_manual_input: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
|
||||
# Relationships
|
||||
versions: Mapped[list["RecipeVersion"]] = relationship(
|
||||
back_populates="recipe", cascade="all, delete-orphan", lazy="selectin"
|
||||
|
||||
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
||||
# What a task is, declared by whoever writes the recipe instead of guessed from its
|
||||
# contents. The last two are not built yet; they are in the enum from the start
|
||||
# because widening a MySQL enum later is an ALTER on a live table.
|
||||
TASK_TYPES = ("note", "measure", "drawing", "xf_compare", "camera_measure")
|
||||
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
|
||||
|
||||
# Types the operator screen treats as "there are quotes to take here".
|
||||
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
||||
@@ -47,6 +47,10 @@ class RecipeTask(Base):
|
||||
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
||||
)
|
||||
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
# The vision graph, serialised by vs-task. Opaque here on purpose: the
|
||||
# server routes it and never interprets it, which is what keeps VisionSuite
|
||||
# out of this container.
|
||||
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||
@@ -89,6 +93,12 @@ class RecipeSubtask(Base):
|
||||
|
||||
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
||||
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
# Which output of the vision graph fills this quote: "<tool_id>.<output>".
|
||||
# A link is an id, not a pointer - the same rule vs-task follows, and for
|
||||
# the same reason: a pointer does not survive being written to disk.
|
||||
vision_output: Mapped[Optional[str]] = mapped_column(
|
||||
String(120), nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""One row per vision execution - not per quote."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class VisionResult(Base):
|
||||
__tablename__ = "vision_results"
|
||||
|
||||
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
|
||||
)
|
||||
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
engine_version: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
executed_on: Mapped[str] = mapped_column(
|
||||
Enum("server", "station", name="vision_executed_on_enum"), nullable=False
|
||||
)
|
||||
station_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("stations.id"), nullable=True
|
||||
)
|
||||
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
# Copied, not referenced: a calibration redone six months from now must not
|
||||
# rewrite what a measurement already taken means. Same for the graph.
|
||||
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
graph_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
executed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<VisionResult task={self.task_id} engine={self.engine_version}>"
|
||||
|
||||
|
||||
class VisionReferenceImage(Base):
|
||||
"""An image kept so a graph can be composed, tried and re-tried without a
|
||||
camera - which is exactly what VisionSuite's source abstraction is for."""
|
||||
|
||||
__tablename__ = "vision_reference_images"
|
||||
|
||||
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
|
||||
)
|
||||
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
station_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("stations.id"), nullable=True
|
||||
)
|
||||
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
engine_version: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
note: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
expected_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
acquired_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<VisionReferenceImage task={self.task_id} path={self.path}>"
|
||||
@@ -3,6 +3,7 @@ import secrets
|
||||
from datetime import datetime
|
||||
|
||||
import bcrypt
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -39,6 +40,31 @@ async def authenticate_user(
|
||||
return user
|
||||
|
||||
|
||||
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
|
||||
"""Check the credentials of the supervisor authorising something.
|
||||
|
||||
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
|
||||
out-of-tolerance value allowed to stand. Lives here rather than beside any one
|
||||
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.
|
||||
"""
|
||||
supervisor = await authenticate_user(db, username, password)
|
||||
if supervisor is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid supervisor credentials",
|
||||
)
|
||||
if not supervisor.has_role("Supervisor") and not supervisor.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User is not authorised as supervisor",
|
||||
)
|
||||
return supervisor
|
||||
|
||||
|
||||
async def login_user(db: AsyncSession, user: User) -> str:
|
||||
"""Generate API key and update last_login for user."""
|
||||
api_key = generate_api_key()
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
"""Measurement service - pass/fail calculation, data storage."""
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
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.task import RecipeSubtask
|
||||
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
|
||||
|
||||
|
||||
def calculate_pass_fail(
|
||||
@@ -39,6 +45,174 @@ def calculate_pass_fail(
|
||||
return "pass", deviation
|
||||
|
||||
|
||||
async def recipe_of_version(db: AsyncSession, version_id: int) -> Recipe | None:
|
||||
"""The recipe a version belongs to - where the rules of measurement live."""
|
||||
result = await db.execute(
|
||||
select(Recipe)
|
||||
.join(RecipeVersion, RecipeVersion.recipe_id == Recipe.id)
|
||||
.where(RecipeVersion.id == version_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _enforce_recipe_rules(
|
||||
recipe: Recipe | None,
|
||||
lot_number: str | None,
|
||||
serial_number: str | None,
|
||||
input_method: str,
|
||||
) -> None:
|
||||
"""Refuse a measurement the recipe does not allow.
|
||||
|
||||
Checked here rather than only on the screen because a rule that lives in the
|
||||
frontend is a suggestion: the keypad can be hidden and the same request still
|
||||
sent. This is the one place every measurement passes through.
|
||||
"""
|
||||
if recipe is None:
|
||||
return
|
||||
|
||||
if recipe.requires_lot and not (lot_number or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe requires a lot number",
|
||||
)
|
||||
if recipe.requires_serial and not (serial_number or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe requires a serial number",
|
||||
)
|
||||
if input_method == "manual" and not recipe.allow_manual_input:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe does not allow values typed by hand: use the caliper",
|
||||
)
|
||||
|
||||
|
||||
def _scope(query, version_id: int, measured_by: int, production_run_id: int | None):
|
||||
"""Narrow a measurement query to the run in progress, or to this operator's work.
|
||||
|
||||
Inside a production the run is the boundary. Outside one - a recipe executed on
|
||||
its own - the boundary is the version and the person doing it, so two operators
|
||||
on the same recipe do not block each other.
|
||||
"""
|
||||
if production_run_id is not None:
|
||||
return query.where(Measurement.production_run_id == production_run_id)
|
||||
return query.where(
|
||||
Measurement.version_id == version_id,
|
||||
Measurement.measured_by == measured_by,
|
||||
Measurement.production_run_id.is_(None),
|
||||
)
|
||||
|
||||
|
||||
async def pending_authorisation(
|
||||
db: AsyncSession,
|
||||
version_id: int,
|
||||
measured_by: int,
|
||||
production_run_id: int | None = None,
|
||||
) -> Measurement | None:
|
||||
"""The out-of-tolerance measurement that is holding everything up, if any.
|
||||
|
||||
Only the *latest* reading of each quote counts. Measuring the same quote again
|
||||
is allowed - the caliper slips, the piece is reseated - and a second reading
|
||||
within tolerance releases the block. What is not allowed is moving on to the
|
||||
next quote while the one in hand is out of tolerance and nobody has said so.
|
||||
|
||||
How many attempts that permits is point 4's business, not this one's. Every
|
||||
reading stays in the record either way.
|
||||
"""
|
||||
latest_per_subtask = _scope(
|
||||
select(func.max(Measurement.id)),
|
||||
version_id, measured_by, production_run_id,
|
||||
).group_by(Measurement.subtask_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(Measurement)
|
||||
.where(
|
||||
Measurement.id.in_(latest_per_subtask),
|
||||
Measurement.pass_fail == "fail",
|
||||
Measurement.supervisor_id.is_(None),
|
||||
)
|
||||
.order_by(Measurement.id)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def task_progress(
|
||||
db: AsyncSession,
|
||||
version_id: int,
|
||||
measured_by: int,
|
||||
production_run_id: int | None = None,
|
||||
) -> list[dict]:
|
||||
"""How far each measurement task of a version has got.
|
||||
|
||||
A task left half done is the thing the operator cannot see today: they walk
|
||||
away from it, come back to the list, and it looks exactly like one never
|
||||
started. Counting quotes against measurements is what tells them apart.
|
||||
|
||||
Documental tasks are not in here. A note cannot be half read, and marking one
|
||||
incomplete for ever would be noise on the only screen that has to stay legible.
|
||||
Which tasks count is read from the declared type, not from whether quotes happen
|
||||
to have been entered yet - the same rule point 2 settled everywhere else.
|
||||
"""
|
||||
rows = (await db.execute(
|
||||
select(RecipeTask.id, RecipeSubtask.id)
|
||||
.join(RecipeSubtask, RecipeSubtask.task_id == RecipeTask.id, isouter=True)
|
||||
.where(
|
||||
RecipeTask.version_id == version_id,
|
||||
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
|
||||
)
|
||||
)).all()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
by_task: dict[int, set[int]] = {}
|
||||
for task_id, subtask_id in rows:
|
||||
quotes = by_task.setdefault(task_id, set())
|
||||
if subtask_id is not None:
|
||||
quotes.add(subtask_id)
|
||||
|
||||
measured = set((await db.execute(
|
||||
_scope(
|
||||
select(Measurement.subtask_id).distinct(),
|
||||
version_id, measured_by, production_run_id,
|
||||
)
|
||||
)).scalars().all())
|
||||
|
||||
progress = []
|
||||
for task_id, subtask_ids in by_task.items():
|
||||
total = len(subtask_ids)
|
||||
done = len(subtask_ids & measured)
|
||||
if total == 0:
|
||||
# Declared as a measurement but with no quotes on it yet: nothing to
|
||||
# take, so nothing to report as missing either.
|
||||
state = "empty"
|
||||
elif done == total:
|
||||
state = "complete"
|
||||
elif done:
|
||||
state = "partial"
|
||||
else:
|
||||
state = "none"
|
||||
progress.append({
|
||||
"task_id": task_id,
|
||||
"quotes": total,
|
||||
"measured": done,
|
||||
"state": state,
|
||||
})
|
||||
progress.sort(key=lambda row: row["task_id"])
|
||||
return progress
|
||||
|
||||
|
||||
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()
|
||||
await db.flush()
|
||||
await db.refresh(measurement)
|
||||
return measurement
|
||||
|
||||
|
||||
async def save_measurement(
|
||||
db: AsyncSession,
|
||||
subtask_id: int,
|
||||
@@ -50,8 +224,18 @@ async def save_measurement(
|
||||
input_method: str = "manual",
|
||||
input_duration_ms: int | None = None,
|
||||
production_run_id: int | None = None,
|
||||
vision_result_id: int | None = None,
|
||||
enforce_tolerance_gate: bool = True,
|
||||
) -> Measurement:
|
||||
"""Save a single measurement with auto-calculated pass/fail."""
|
||||
"""Save a single measurement with auto-calculated pass/fail.
|
||||
|
||||
``enforce_tolerance_gate=False`` skips only the pending-authorisation check
|
||||
below - everything else (verdict, recipe rules, deviation) still applies.
|
||||
This is for a caller that has already evaluated the gate itself for a whole
|
||||
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
|
||||
the gate models "moving on" between measurements taken one after another,
|
||||
which does not exist inside a single camera acquisition.
|
||||
"""
|
||||
# Get subtask for tolerance values
|
||||
result = await db.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||
@@ -60,6 +244,25 @@ async def save_measurement(
|
||||
if subtask is None:
|
||||
raise ValueError(f"Subtask {subtask_id} not found")
|
||||
|
||||
recipe = await recipe_of_version(db, version_id)
|
||||
_enforce_recipe_rules(recipe, lot_number, serial_number, input_method)
|
||||
|
||||
# No moving on while a quote is out of tolerance and unauthorised. Measuring
|
||||
# that same quote again is the way out that does not need the capoturno; the
|
||||
# next quote is not.
|
||||
if enforce_tolerance_gate:
|
||||
blocking = await pending_authorisation(
|
||||
db, version_id, measured_by, production_run_id,
|
||||
)
|
||||
if blocking is not None and blocking.subtask_id != subtask_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||
"authorise it or measure that quote again before going on"
|
||||
),
|
||||
)
|
||||
|
||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||
|
||||
measurement = Measurement(
|
||||
@@ -74,6 +277,7 @@ async def save_measurement(
|
||||
input_method=input_method,
|
||||
input_duration_ms=input_duration_ms,
|
||||
production_run_id=production_run_id,
|
||||
vision_result_id=vision_result_id,
|
||||
)
|
||||
db.add(measurement)
|
||||
await db.flush()
|
||||
|
||||
@@ -55,6 +55,11 @@ HEADER = [
|
||||
"input_duration_ms",
|
||||
"measured_by",
|
||||
"measured_at",
|
||||
# A value outside tolerance that was allowed to stand carries the name of who
|
||||
# allowed it. Without these two columns the file would show the failure and
|
||||
# not the decision, which is the half an auditor asks about.
|
||||
"authorised_by",
|
||||
"authorised_at",
|
||||
]
|
||||
|
||||
|
||||
@@ -126,6 +131,8 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
||||
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
||||
measurement.measured_by,
|
||||
measurement.measured_at.isoformat() if measurement.measured_at else "",
|
||||
measurement.supervisor_id or "",
|
||||
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
|
||||
])
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
@@ -26,7 +26,9 @@ from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import MEASURING_TASK_TYPES, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import auth_service, production_export_service
|
||||
from src.backend.services import (
|
||||
auth_service, measurement_service, production_export_service,
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -137,27 +139,9 @@ async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor authorisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
|
||||
"""Check the credentials of the supervisor authorising a stop or a close.
|
||||
|
||||
Uses authenticate_user rather than a full login: logging in would rotate the
|
||||
supervisor's API key and knock out whatever session they have open elsewhere.
|
||||
"""
|
||||
supervisor = await auth_service.authenticate_user(db, username, password)
|
||||
if supervisor is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid supervisor credentials",
|
||||
)
|
||||
if not supervisor.has_role("Supervisor") and not supervisor.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User is not authorised as supervisor",
|
||||
)
|
||||
return supervisor
|
||||
# The capoturno check lives in auth_service, next to the rest of the credential
|
||||
# handling: a line stop, a closure and an out-of-tolerance value all ask the same
|
||||
# question, and only one of the three is about production.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -243,6 +227,20 @@ async def open_run(
|
||||
detail="Recipe has no current version",
|
||||
)
|
||||
|
||||
# Traceability the recipe declares compulsory has to be there before the first
|
||||
# measurement, not after it: a lot entered halfway through leaves the values
|
||||
# taken up to that point unattributable.
|
||||
if recipe.requires_lot and not (data.lot_number or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe requires a lot number to start a production",
|
||||
)
|
||||
if recipe.requires_serial and not (data.serial_number or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe requires a serial number to start a production",
|
||||
)
|
||||
|
||||
now = _now()
|
||||
# The interval is copied, not referenced: editing the recipe mid-production must
|
||||
# not move the deadline of a run already under way.
|
||||
@@ -306,6 +304,21 @@ async def complete_cycle(
|
||||
"""
|
||||
_require_measurable(run)
|
||||
|
||||
# A cycle that contains a quote out of tolerance is not a finished cycle. This
|
||||
# is the same gate as on the next measurement: closing the cycle would be
|
||||
# another way of walking past it.
|
||||
blocking = await measurement_service.pending_authorisation(
|
||||
db, run.version_id, user.id, run.id,
|
||||
)
|
||||
if blocking is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||
"the cycle cannot be closed until it is authorised"
|
||||
),
|
||||
)
|
||||
|
||||
measure_ids = await measurement_task_ids(db, run.version_id)
|
||||
closes_cycle = (
|
||||
task_id is None or task_id not in measure_ids or task_id == measure_ids[-1]
|
||||
|
||||
@@ -19,6 +19,30 @@ from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Columns of the recipe header itself, as opposed to the versioned content. Both
|
||||
# update paths - copy-on-write and in-place - write exactly these, so the list
|
||||
# lives once: a field added to one and forgotten in the other would appear to save
|
||||
# and then quietly not, depending on whether the version had measurements.
|
||||
HEADER_FIELDS = (
|
||||
"name",
|
||||
"description",
|
||||
"image_path",
|
||||
"measurement_interval_minutes",
|
||||
"requires_lot",
|
||||
"requires_serial",
|
||||
"allow_manual_input",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
|
||||
"""Return a recipe or raise 404."""
|
||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||
@@ -64,6 +88,11 @@ async def _copy_tasks_to_version(
|
||||
file_path=task.file_path,
|
||||
file_type=task.file_type,
|
||||
annotations_json=task.annotations_json,
|
||||
# Carried over explicitly, same reasoning as task_type above: a
|
||||
# copy-on-write version that dropped the camera graph would leave
|
||||
# a camera_measure task unable to execute from the moment a
|
||||
# recipe with measurements is next edited.
|
||||
vision_json=task.vision_json,
|
||||
)
|
||||
db.add(new_task)
|
||||
await db.flush() # get new_task.id
|
||||
@@ -81,6 +110,7 @@ async def _copy_tasks_to_version(
|
||||
ltl=sub.ltl,
|
||||
unit=sub.unit,
|
||||
image_path=sub.image_path,
|
||||
vision_output=sub.vision_output,
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
@@ -138,6 +168,9 @@ async def create_recipe(
|
||||
description=data.description,
|
||||
image_path=data.image_path,
|
||||
measurement_interval_minutes=data.measurement_interval_minutes,
|
||||
requires_lot=data.requires_lot,
|
||||
requires_serial=data.requires_serial,
|
||||
allow_manual_input=data.allow_manual_input,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(recipe)
|
||||
@@ -271,15 +304,7 @@ async def create_new_version(
|
||||
await db.flush()
|
||||
|
||||
# Apply header updates
|
||||
update_fields: dict = {}
|
||||
if data.name is not None:
|
||||
update_fields["name"] = data.name
|
||||
if data.description is not None:
|
||||
update_fields["description"] = data.description
|
||||
if data.image_path is not None:
|
||||
update_fields["image_path"] = data.image_path
|
||||
if data.measurement_interval_minutes is not None:
|
||||
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
||||
update_fields = _header_fields(data)
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
@@ -368,16 +393,7 @@ async def update_current_version(
|
||||
data: RecipeUpdate,
|
||||
) -> RecipeVersion:
|
||||
"""Update recipe header in-place on the current version (no copy-on-write)."""
|
||||
# Apply header updates (name, description, image_path)
|
||||
update_fields: dict = {}
|
||||
if data.name is not None:
|
||||
update_fields["name"] = data.name
|
||||
if data.description is not None:
|
||||
update_fields["description"] = data.description
|
||||
if data.image_path is not None:
|
||||
update_fields["image_path"] = data.image_path
|
||||
if data.measurement_interval_minutes is not None:
|
||||
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
||||
update_fields = _header_fields(data)
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Route the image to the worker, map the outputs onto the quotes, save.
|
||||
|
||||
This module never imports VisionSuite - see test_vision_boundary.py. It speaks
|
||||
to the worker over HTTP and stays ignorant of how a number was produced.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
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.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
from src.backend.services import measurement_service
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.vision_worker_url}/run",
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail=f"the vision worker did not answer in time: {exc}",
|
||||
) from exc
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker refused the job: {response.text}",
|
||||
)
|
||||
|
||||
try:
|
||||
report = response.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
||||
) from exc
|
||||
|
||||
if not isinstance(report, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: expected a JSON object",
|
||||
)
|
||||
missing = {"outputs", "failures", "engine_version"} - report.keys()
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=(
|
||||
"the vision worker's answer was malformed: missing "
|
||||
f"{', '.join(sorted(missing))}"
|
||||
),
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
async def execute_task(
|
||||
db: AsyncSession,
|
||||
task: RecipeTask,
|
||||
image_bytes: bytes,
|
||||
measured_by: int,
|
||||
*,
|
||||
version_id: int,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None,
|
||||
) -> tuple[VisionResult, list[Measurement]]:
|
||||
if not task.vision_json:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="this task carries no vision graph",
|
||||
)
|
||||
|
||||
# Ordered by marker_number - the number the operator sees on the annotated
|
||||
# image and in reports (RecipeSubtask.__repr__, statistics.py, report_service.py
|
||||
# all key off it). Which quote is saved first must never be an accident of
|
||||
# how SQLite/MySQL happens to return rows.
|
||||
quotes = (await db.execute(
|
||||
select(RecipeSubtask)
|
||||
.where(RecipeSubtask.task_id == task.id)
|
||||
.where(RecipeSubtask.vision_output.is_not(None))
|
||||
.order_by(RecipeSubtask.marker_number)
|
||||
)).scalars().all()
|
||||
if not quotes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="no quote on this task names a vision output",
|
||||
)
|
||||
|
||||
# Ruling R10: the gate is evaluated once per acquisition, not once per
|
||||
# quote. `save_measurement`'s own gate models "moving on" between
|
||||
# measurements taken one after another - a real later moment where the
|
||||
# operator could leave a bad reading behind. A camera acquisition produces
|
||||
# every quote of this task at the same instant: there is no "moving on"
|
||||
# between them, so applying the sequential rule inside the batch would
|
||||
# block on a fail this very request just saved. Check it once here,
|
||||
# against the whole set this acquisition is about to produce, and pass
|
||||
# enforce_tolerance_gate=False to every save_measurement call below.
|
||||
#
|
||||
# Checked before the worker runs (M3): a blocked operator finds out
|
||||
# without burning a whole vision execution first - the worker is the
|
||||
# expensive part, the gate check is not.
|
||||
blocking = await measurement_service.pending_authorisation(
|
||||
db, version_id, measured_by, production_run_id,
|
||||
)
|
||||
quote_ids = {q.id for q in quotes}
|
||||
if blocking is not None and blocking.subtask_id not in quote_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||
"authorise it or measure that quote again before going on"
|
||||
),
|
||||
)
|
||||
|
||||
report = await _call_worker(image_bytes, task.vision_json)
|
||||
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]
|
||||
if missing:
|
||||
failed = ", ".join(f["tool_name"] for f in report["failures"])
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=(
|
||||
f"the graph did not produce {', '.join(missing)}"
|
||||
+ (f" (tools that failed: {failed})" if failed else "")
|
||||
),
|
||||
)
|
||||
|
||||
result = VisionResult(
|
||||
task_id=task.id,
|
||||
engine_version=report["engine_version"],
|
||||
executed_on="server",
|
||||
graph_snapshot=task.vision_json,
|
||||
duration_ms=report.get("duration_ms"),
|
||||
)
|
||||
db.add(result)
|
||||
await db.flush()
|
||||
|
||||
saved: list[Measurement] = []
|
||||
for quote in quotes:
|
||||
saved.append(await measurement_service.save_measurement(
|
||||
db,
|
||||
subtask_id=quote.id,
|
||||
version_id=version_id,
|
||||
measured_by=measured_by,
|
||||
value=float(outputs[quote.vision_output]),
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
input_method="camera",
|
||||
production_run_id=production_run_id,
|
||||
vision_result_id=result.id,
|
||||
enforce_tolerance_gate=False,
|
||||
))
|
||||
|
||||
return result, saved
|
||||
|
||||
|
||||
async def save_reference_image(
|
||||
db: AsyncSession,
|
||||
task_id: int,
|
||||
image_bytes: bytes,
|
||||
*,
|
||||
note: str | None = None,
|
||||
station_id: int | None = None,
|
||||
device_code: str | None = None,
|
||||
calibration_snapshot: dict | None = None,
|
||||
extension: str = "png",
|
||||
) -> VisionReferenceImage:
|
||||
"""Keep an image so the graph can be composed and re-tried without a camera.
|
||||
|
||||
`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.
|
||||
"""
|
||||
# `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)
|
||||
|
||||
reference = VisionReferenceImage(
|
||||
task_id=task_id,
|
||||
path=str(Path("vision") / "reference" / str(task_id) / name),
|
||||
note=note,
|
||||
station_id=station_id,
|
||||
device_code=device_code,
|
||||
calibration_snapshot=calibration_snapshot,
|
||||
)
|
||||
db.add(reference)
|
||||
await db.flush()
|
||||
await db.refresh(reference)
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="this task carries no vision graph",
|
||||
)
|
||||
return await _call_worker(image_bytes, task.vision_json)
|
||||
@@ -224,6 +224,10 @@ async def create_test_recipe(
|
||||
code=code,
|
||||
name=name,
|
||||
description="A recipe for testing",
|
||||
# Typing is allowed here so that tests about something else can still save
|
||||
# a measurement. The rule itself is exercised by test_recipe_rules.py, on
|
||||
# recipes that declare it explicitly.
|
||||
allow_manual_input=True,
|
||||
created_by=user_id,
|
||||
)
|
||||
session.add(recipe)
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Point 5: a quote out of tolerance stops the line until someone says otherwise.
|
||||
|
||||
The gate existed on screen and nowhere else - a modal that opened, checked a
|
||||
password and closed, while the measurement had already been saved. Dismissing it
|
||||
was enough to carry on. These tests are about the part that cannot be dismissed.
|
||||
"""
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
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 auth_service
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
GOOD = 10.0 # inside every limit of the fixture quote
|
||||
OUT = 99.0 # far outside
|
||||
|
||||
|
||||
async def _supervisor(db_session, username="capo-tol", password="TurnoPwd1"):
|
||||
user = await auth_service.create_user(
|
||||
db_session, username=username, password=password,
|
||||
display_name="Capo Turno", roles=["Supervisor"],
|
||||
)
|
||||
await db_session.commit()
|
||||
return user, password
|
||||
|
||||
|
||||
async def _two_quotes(db_session, recipe_id: int):
|
||||
"""The fixture recipe plus a second quote, so there is a 'next' one."""
|
||||
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()
|
||||
first = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
|
||||
second = RecipeSubtask(
|
||||
task_id=task.id, marker_number=2, description="Seconda quota",
|
||||
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||
)
|
||||
db_session.add(second)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(second)
|
||||
return version.id, first, second
|
||||
|
||||
|
||||
async def _measure(client, user, subtask_id, version_id, value, run_id=None):
|
||||
body = {"subtask_id": subtask_id, "version_id": version_id, "value": value}
|
||||
if run_id is not None:
|
||||
body["production_run_id"] = run_id
|
||||
return await client.post(
|
||||
"/api/measurements/", headers=auth_headers(user), json=body,
|
||||
)
|
||||
|
||||
|
||||
async def _pending(client, user, version_id, run_id=None):
|
||||
params = {"version_id": version_id}
|
||||
if run_id is not None:
|
||||
params["production_run_id"] = run_id
|
||||
return await client.get(
|
||||
"/api/measurements/pending-authorisation",
|
||||
headers=auth_headers(user), params=params,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_next_quote_is_refused_while_one_is_out_of_tolerance(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-NEXT")
|
||||
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
bad = await _measure(client, measurement_tec_user, first.id, version_id, OUT)
|
||||
assert bad.status_code == 200
|
||||
assert bad.json()["pass_fail"] == "fail"
|
||||
|
||||
refused = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||
assert refused.status_code == 409
|
||||
assert "supervisor" in refused.json()["detail"].lower()
|
||||
|
||||
|
||||
async def test_the_same_quote_can_be_measured_again(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""The caliper slips, the piece is reseated. Trying again is not going on."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-AGAIN")
|
||||
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
await _measure(client, measurement_tec_user, first.id, version_id, OUT)
|
||||
again = await _measure(client, measurement_tec_user, first.id, version_id, OUT)
|
||||
assert again.status_code == 200, "rimisurare la stessa quota deve restare possibile"
|
||||
|
||||
|
||||
async def test_a_good_reading_of_the_same_quote_releases_the_block(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""Only the latest reading of a quote counts: it is in tolerance now."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-FIXED")
|
||||
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
await _measure(client, measurement_tec_user, first.id, version_id, OUT)
|
||||
await _measure(client, measurement_tec_user, first.id, version_id, GOOD)
|
||||
|
||||
ahead = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||
assert ahead.status_code == 200
|
||||
|
||||
# Both readings stay on record - how many are allowed is point 4's business.
|
||||
rows = (await db_session.execute(
|
||||
select(Measurement).where(Measurement.subtask_id == first.id)
|
||||
)).scalars().all()
|
||||
assert len(rows) == 2
|
||||
|
||||
|
||||
async def test_a_warning_does_not_block(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""Outside the warning limits but inside tolerance is still inside tolerance."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-WARN")
|
||||
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
warned = await _measure(client, measurement_tec_user, first.id, version_id, 10.4)
|
||||
assert warned.json()["pass_fail"] == "warning"
|
||||
|
||||
ahead = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||
assert ahead.status_code == 200
|
||||
|
||||
|
||||
async def test_pending_authorisation_names_the_blocking_quote(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""What the screen asks on load, so a reload is not a way past the gate."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-PEND")
|
||||
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
assert (await _pending(client, measurement_tec_user, version_id)).json() is None
|
||||
|
||||
bad = (await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT,
|
||||
)).json()
|
||||
|
||||
pending = (await _pending(client, measurement_tec_user, version_id)).json()
|
||||
assert pending["id"] == bad["id"]
|
||||
assert pending["subtask_id"] == first.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The authorisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_supervisor_authorisation_is_recorded_and_releases_the_block(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-AUTH")
|
||||
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||
supervisor, password = await _supervisor(db_session)
|
||||
|
||||
bad = (await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT,
|
||||
)).json()
|
||||
|
||||
authorised = await client.post(
|
||||
f"/api/measurements/{bad['id']}/authorise",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": supervisor.username,
|
||||
"supervisor_password": password,
|
||||
},
|
||||
)
|
||||
assert authorised.status_code == 200, authorised.text
|
||||
body = authorised.json()
|
||||
# Who said yes is on the measurement itself, which is the whole point.
|
||||
assert body["supervisor_id"] == supervisor.id
|
||||
assert body["authorised_at"] is not None
|
||||
|
||||
ahead = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||
assert ahead.status_code == 200
|
||||
|
||||
|
||||
async def test_authorisation_refuses_someone_who_is_not_a_supervisor(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-NOSUP")
|
||||
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
bad = (await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT,
|
||||
)).json()
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/measurements/{bad['id']}/authorise",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": maker_user.username,
|
||||
"supervisor_password": "testpassword123",
|
||||
},
|
||||
)
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
# And the block is still there.
|
||||
refused = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||
assert refused.status_code == 409
|
||||
|
||||
|
||||
async def test_authorisation_refuses_wrong_credentials(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-BADPW")
|
||||
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||
supervisor, _password = await _supervisor(db_session, username="capo-badpw")
|
||||
|
||||
bad = (await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT,
|
||||
)).json()
|
||||
resp = await client.post(
|
||||
f"/api/measurements/{bad['id']}/authorise",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": supervisor.username,
|
||||
"supervisor_password": "sbagliata",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_there_is_nothing_to_authorise_on_a_good_measurement(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-GOOD")
|
||||
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||
supervisor, password = await _supervisor(db_session, username="capo-good")
|
||||
|
||||
good = (await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, GOOD,
|
||||
)).json()
|
||||
resp = await client.post(
|
||||
f"/api/measurements/{good['id']}/authorise",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": supervisor.username,
|
||||
"supervisor_password": password,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The production run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_cycle_cannot_close_over_an_unauthorised_fail(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""Closing the cycle would be another way of walking past the quote."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-CYC")
|
||||
recipe.measurement_interval_minutes = 10
|
||||
station = Station(
|
||||
code="ST-TOL", name="Stazione", active=True, created_by=admin_user.id,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)).json()
|
||||
|
||||
await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT, run_id=run["id"],
|
||||
)
|
||||
|
||||
refused = await client.post(
|
||||
f"/api/production-runs/{run['id']}/cycle",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert refused.status_code == 409
|
||||
|
||||
supervisor, password = await _supervisor(db_session, username="capo-cyc")
|
||||
blocking = (await _pending(
|
||||
client, measurement_tec_user, version_id, run_id=run["id"],
|
||||
)).json()
|
||||
await client.post(
|
||||
f"/api/measurements/{blocking['id']}/authorise",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": supervisor.username,
|
||||
"supervisor_password": password,
|
||||
},
|
||||
)
|
||||
|
||||
allowed = await client.post(
|
||||
f"/api/production-runs/{run['id']}/cycle",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert allowed.status_code == 200
|
||||
assert allowed.json()["cycle_count"] == 1
|
||||
|
||||
|
||||
async def test_the_statistics_file_carries_the_decision(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
tmp_path, monkeypatch,
|
||||
):
|
||||
"""The file has to show the failure and who let it stand, not just the failure."""
|
||||
from src.backend.config import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
|
||||
)
|
||||
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-CSV")
|
||||
station = Station(
|
||||
code="ST-CSV", name="Stazione", active=True, created_by=admin_user.id,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||
supervisor, password = await _supervisor(db_session, username="capo-csv")
|
||||
creds = {
|
||||
"supervisor_username": supervisor.username, "supervisor_password": password,
|
||||
}
|
||||
|
||||
run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)).json()
|
||||
bad = (await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT, run_id=run["id"],
|
||||
)).json()
|
||||
await client.post(
|
||||
f"/api/measurements/{bad['id']}/authorise",
|
||||
headers=auth_headers(measurement_tec_user), json=creds,
|
||||
)
|
||||
|
||||
closed = (await client.post(
|
||||
f"/api/production-runs/{run['id']}/close",
|
||||
headers=auth_headers(measurement_tec_user), json=creds,
|
||||
)).json()
|
||||
|
||||
text = (tmp_path / closed["statistics_path"]).read_text(encoding="utf-8-sig")
|
||||
header, row = [line for line in text.splitlines() if line.strip()]
|
||||
assert "authorised_by" in header and "authorised_at" in header
|
||||
assert str(supervisor.id) in row
|
||||
|
||||
|
||||
async def test_a_fail_from_another_production_does_not_block_this_one(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
tmp_path, monkeypatch,
|
||||
):
|
||||
"""The run is the boundary: an old production must not stop a new one."""
|
||||
# Closing a run writes the statistics file; pointed at tmp_path so the test
|
||||
# does not leave one in the repository's uploads directory.
|
||||
from src.backend.config import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
|
||||
)
|
||||
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-SCOPE")
|
||||
station = Station(
|
||||
code="ST-SCOPE", name="Stazione", active=True, created_by=admin_user.id,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
first_run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)).json()
|
||||
await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT,
|
||||
run_id=first_run["id"],
|
||||
)
|
||||
|
||||
supervisor, password = await _supervisor(db_session, username="capo-scope")
|
||||
await client.post(
|
||||
f"/api/production-runs/{first_run['id']}/close",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": supervisor.username,
|
||||
"supervisor_password": password,
|
||||
},
|
||||
)
|
||||
|
||||
second_run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)).json()
|
||||
resp = await _measure(
|
||||
client, measurement_tec_user, second.id, version_id, GOOD,
|
||||
run_id=second_run["id"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,280 @@
|
||||
"""What a recipe is allowed to demand of the operator (points 8 and 9).
|
||||
|
||||
Two rules that used to be nobody's: whether the lot and the serial are compulsory,
|
||||
and whether a value may be typed rather than read from the caliper. Both are
|
||||
declared on the recipe and enforced on the server, because a rule enforced only by
|
||||
the screen is a suggestion - the keypad can be hidden and the same request still
|
||||
sent by hand.
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.recipe import Recipe, 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
|
||||
|
||||
|
||||
async def _rules(db_session, recipe: Recipe, **flags) -> Recipe:
|
||||
for key, value in flags.items():
|
||||
setattr(recipe, key, value)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(recipe)
|
||||
return recipe
|
||||
|
||||
|
||||
async def _first_subtask(db_session, recipe_id: int) -> RecipeSubtask:
|
||||
row = await db_session.execute(
|
||||
select(RecipeSubtask)
|
||||
.join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
|
||||
.join(RecipeVersion, RecipeVersion.id == RecipeTask.version_id)
|
||||
.where(RecipeVersion.recipe_id == recipe_id)
|
||||
)
|
||||
return row.scalars().first()
|
||||
|
||||
|
||||
async def _current_version_id(db_session, recipe_id: int) -> int:
|
||||
row = await db_session.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.recipe_id == recipe_id,
|
||||
RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
return row.scalar_one().id
|
||||
|
||||
|
||||
async def _measure(client, user, subtask_id, version_id, **extra):
|
||||
return await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(user),
|
||||
json={
|
||||
"subtask_id": subtask_id, "version_id": version_id, "value": 10.0, **extra,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The rules are part of the recipe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_new_recipe_forbids_typing_by_default(client: AsyncClient, maker_user):
|
||||
"""The caliper is the instrument: typing is what has to be asked for."""
|
||||
resp = await client.post(
|
||||
"/api/recipes",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"code": "REG-DEF", "name": "Regole di default"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["allow_manual_input"] is False
|
||||
assert body["requires_lot"] is False
|
||||
assert body["requires_serial"] is False
|
||||
|
||||
|
||||
async def test_recipe_carries_the_rules_it_was_created_with(
|
||||
client: AsyncClient, maker_user,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/recipes",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"code": "REG-SET", "name": "Con regole",
|
||||
"requires_lot": True, "requires_serial": True, "allow_manual_input": True,
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
assert (body["requires_lot"], body["requires_serial"], body["allow_manual_input"]) \
|
||||
== (True, True, True)
|
||||
|
||||
|
||||
async def test_rules_can_be_switched_off_again(
|
||||
client: AsyncClient, maker_user, db_session,
|
||||
):
|
||||
"""False must reach the database: a flag that only ever turns on is a trap."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="REG-OFF")
|
||||
await _rules(db_session, recipe, requires_lot=True, allow_manual_input=True)
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"requires_lot": False, "allow_manual_input": False},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["requires_lot"] is False
|
||||
assert resp.json()["allow_manual_input"] is False
|
||||
|
||||
|
||||
async def test_rules_survive_a_new_version(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""Editing a recipe with measurements copies it; the rules must come along."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="REG-VER")
|
||||
await _rules(db_session, recipe, requires_lot=True)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
# A measurement on the current version forces copy-on-write on the next edit.
|
||||
await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, lot_number="L-1",
|
||||
)
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"name": "Rinominata"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["requires_lot"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 8 - traceability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_measurement_without_the_required_lot_is_refused(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-LOT")
|
||||
await _rules(db_session, recipe, requires_lot=True)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
refused = await _measure(client, measurement_tec_user, subtask.id, version_id)
|
||||
assert refused.status_code == 422
|
||||
|
||||
# Blank is not a lot number either.
|
||||
blank = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, lot_number=" ",
|
||||
)
|
||||
assert blank.status_code == 422
|
||||
|
||||
accepted = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, lot_number="LOT-9",
|
||||
)
|
||||
assert accepted.status_code == 200
|
||||
|
||||
|
||||
async def test_measurement_without_the_required_serial_is_refused(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-SER")
|
||||
await _rules(db_session, recipe, requires_serial=True)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id,
|
||||
)).status_code == 422
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, serial_number="SN-1",
|
||||
)).status_code == 200
|
||||
|
||||
|
||||
async def test_optional_traceability_still_accepts_nothing(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""Recipes that do not ask for a lot must go on working as they did."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-OPT")
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id,
|
||||
)).status_code == 200
|
||||
|
||||
|
||||
async def test_production_cannot_start_without_the_required_lot(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""The lot has to be there before the first value, not after it."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-RUN")
|
||||
await _rules(db_session, recipe, requires_lot=True)
|
||||
station = Station(
|
||||
code="ST-TRC", name="Stazione", active=True, created_by=admin_user.id,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
|
||||
refused = await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)
|
||||
assert refused.status_code == 422
|
||||
|
||||
started = await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"station_code": station.code, "recipe_id": recipe.id, "lot_number": "L-7",
|
||||
},
|
||||
)
|
||||
assert started.status_code == 201
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 9 - typed values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_typed_value_is_refused_when_the_recipe_forbids_it(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-NO")
|
||||
await _rules(db_session, recipe, allow_manual_input=False)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
refused = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, input_method="manual",
|
||||
)
|
||||
assert refused.status_code == 422
|
||||
assert "caliper" in refused.json()["detail"].lower()
|
||||
|
||||
|
||||
async def test_the_caliper_is_always_welcome(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-USB")
|
||||
await _rules(db_session, recipe, allow_manual_input=False)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
resp = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id,
|
||||
input_method="usb_caliper",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["input_method"] == "usb_caliper"
|
||||
|
||||
|
||||
async def test_typing_is_allowed_where_the_recipe_says_so(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-YES")
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
resp = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, input_method="manual",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
async def test_the_default_input_method_is_covered_by_the_rule(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""A request that says nothing about how the value was entered counts as typed.
|
||||
|
||||
Otherwise omitting the field would be the way around the rule.
|
||||
"""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-DEF")
|
||||
await _rules(db_session, recipe, allow_manual_input=False)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id,
|
||||
)).status_code == 422
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Point 10: a task left half done has to look different from one never opened.
|
||||
|
||||
The task list showed both the same way, so an operator coming back to a recipe
|
||||
had to remember where they had got to - or measure something twice. These tests
|
||||
are about the count the list now puts on each card.
|
||||
"""
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def _version_and_task(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()
|
||||
return version, task
|
||||
|
||||
|
||||
async def _add_quote(db_session, task_id: int, marker: int):
|
||||
quote = RecipeSubtask(
|
||||
task_id=task_id, marker_number=marker, description=f"Quota {marker}",
|
||||
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||
)
|
||||
db_session.add(quote)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(quote)
|
||||
return quote
|
||||
|
||||
|
||||
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},
|
||||
)
|
||||
|
||||
|
||||
async def _progress(client, user, version_id):
|
||||
resp = await client.get(
|
||||
"/api/measurements/task-progress",
|
||||
headers=auth_headers(user),
|
||||
params={"version_id": version_id},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return {row["task_id"]: row for row in resp.json()["tasks"]}
|
||||
|
||||
|
||||
async def test_untouched_task_reads_as_none(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task = await _version_and_task(db_session, recipe.id)
|
||||
|
||||
rows = await _progress(client, measurement_tec_user, version.id)
|
||||
assert rows[task.id]["state"] == "none"
|
||||
assert rows[task.id]["measured"] == 0
|
||||
|
||||
|
||||
async def test_half_measured_task_reads_as_partial(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task = await _version_and_task(db_session, recipe.id)
|
||||
first = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
await _add_quote(db_session, task.id, 2)
|
||||
|
||||
await _measure(client, measurement_tec_user, first.id, version.id)
|
||||
|
||||
rows = await _progress(client, measurement_tec_user, version.id)
|
||||
assert rows[task.id] == {
|
||||
"task_id": task.id, "quotes": 2, "measured": 1, "state": "partial",
|
||||
}
|
||||
|
||||
|
||||
async def test_every_quote_taken_reads_as_complete(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task = await _version_and_task(db_session, recipe.id)
|
||||
first = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
second = await _add_quote(db_session, task.id, 2)
|
||||
|
||||
await _measure(client, measurement_tec_user, first.id, version.id)
|
||||
await _measure(client, measurement_tec_user, second.id, version.id)
|
||||
|
||||
rows = await _progress(client, measurement_tec_user, version.id)
|
||||
assert rows[task.id]["state"] == "complete"
|
||||
assert rows[task.id]["measured"] == 2
|
||||
|
||||
|
||||
async def test_measuring_the_same_quote_twice_does_not_inflate_the_count(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
"""Two readings of one quote are one quote done, not two.
|
||||
|
||||
Re-measuring is allowed - it is how an operator recovers from a caliper that
|
||||
slipped - and counting attempts would show a task of two quotes as finished
|
||||
after one of them had been measured twice.
|
||||
"""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task = await _version_and_task(db_session, recipe.id)
|
||||
first = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
await _add_quote(db_session, task.id, 2)
|
||||
|
||||
await _measure(client, measurement_tec_user, first.id, version.id, 10.0)
|
||||
await _measure(client, measurement_tec_user, first.id, version.id, 10.1)
|
||||
|
||||
rows = await _progress(client, measurement_tec_user, version.id)
|
||||
assert rows[task.id]["measured"] == 1
|
||||
assert rows[task.id]["state"] == "partial"
|
||||
|
||||
|
||||
async def test_documental_tasks_are_left_out(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
"""A note cannot be half read; carrying it as unfinished for ever is noise."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task = await _version_and_task(db_session, recipe.id)
|
||||
note = RecipeTask(
|
||||
version_id=version.id, order_index=5, title="Leggere prima",
|
||||
task_type="note",
|
||||
)
|
||||
db_session.add(note)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(note)
|
||||
|
||||
rows = await _progress(client, measurement_tec_user, version.id)
|
||||
assert note.id not in rows
|
||||
assert task.id in rows
|
||||
|
||||
|
||||
async def test_a_measurement_task_without_quotes_is_not_reported_as_missing(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
"""Declared as a measurement, no quotes entered yet: nothing to take."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, _task = await _version_and_task(db_session, recipe.id)
|
||||
empty = RecipeTask(
|
||||
version_id=version.id, order_index=6, title="Da compilare",
|
||||
task_type="measure",
|
||||
)
|
||||
db_session.add(empty)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(empty)
|
||||
|
||||
rows = await _progress(client, measurement_tec_user, version.id)
|
||||
assert rows[empty.id]["state"] == "empty"
|
||||
assert rows[empty.id]["quotes"] == 0
|
||||
|
||||
|
||||
async def test_another_operators_work_is_not_counted_as_yours(
|
||||
client: AsyncClient, db_session, measurement_tec_user, admin_user,
|
||||
):
|
||||
"""Outside a production the boundary is the operator, as it is everywhere else."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task = await _version_and_task(db_session, recipe.id)
|
||||
first = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
|
||||
await _measure(client, admin_user, first.id, version.id)
|
||||
|
||||
rows = await _progress(client, measurement_tec_user, version.id)
|
||||
assert rows[task.id]["state"] == "none"
|
||||
|
||||
|
||||
async def test_inside_a_production_the_run_is_the_boundary(
|
||||
client: AsyncClient, db_session, measurement_tec_user, admin_user,
|
||||
):
|
||||
"""A quote measured before the production opened is not part of it.
|
||||
|
||||
The list is read during the run, and carrying yesterday's readings into it
|
||||
would show a task as finished that this production has not touched.
|
||||
"""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task = await _version_and_task(db_session, recipe.id)
|
||||
first = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
|
||||
# Measured outside any production.
|
||||
await _measure(client, measurement_tec_user, first.id, version.id)
|
||||
|
||||
station = Station(
|
||||
code="ST-PROG", name="Stazione", active=True, created_by=admin_user.id,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)).json()
|
||||
|
||||
resp = await client.get(
|
||||
"/api/measurements/task-progress",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
params={"version_id": version.id, "production_run_id": run["id"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
rows = {row["task_id"]: row for row in resp.json()["tasks"]}
|
||||
assert rows[task.id]["state"] == "none"
|
||||
|
||||
await client.post(
|
||||
"/api/measurements/", headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": first.id, "version_id": version.id, "value": 10.0,
|
||||
"production_run_id": run["id"],
|
||||
},
|
||||
)
|
||||
resp = await client.get(
|
||||
"/api/measurements/task-progress",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
params={"version_id": version.id, "production_run_id": run["id"]},
|
||||
)
|
||||
rows = {row["task_id"]: row for row in resp.json()["tasks"]}
|
||||
assert rows[task.id]["state"] == "complete"
|
||||
|
||||
|
||||
async def test_progress_needs_a_measurement_role(
|
||||
client: AsyncClient, db_session, maker_user, measurement_tec_user,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, _task = await _version_and_task(db_session, recipe.id)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/measurements/task-progress",
|
||||
headers=auth_headers(maker_user),
|
||||
params={"version_id": version.id},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Il server non importa VisionSuite, e questo lo verifica.
|
||||
|
||||
Senza qualcuno che lo controlli, il confine si perde in silenzio: basta un
|
||||
import comodo perché l'immagine dell'API torni da cinque gigabyte e il motivo
|
||||
per cui il worker esiste svanisca senza che nessuna prova diventi rossa.
|
||||
"""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1]
|
||||
|
||||
# Full dotted prefixes, not bare roots: "src" alone would forbid every import
|
||||
# the backend legitimately makes of itself (src.backend.*), so the entries
|
||||
# for the vision tree are multi-segment ("src.vision", "src.vision_worker")
|
||||
# while VisionSuite's own top-level packages stay single-segment.
|
||||
#
|
||||
# M2: "Acquire" used to sit here as a stand-in for vs-camera, but it is a
|
||||
# class/SDK name (Balluff's "mvIMPACT Acquire" SDK), never an import root -
|
||||
# it could never match. vs-camera imports under the same `visionsuite`
|
||||
# namespace as vs-core (see
|
||||
# vendor/visionsuite/packages/vs-camera/pyproject.toml: "il codice si importa
|
||||
# come visionsuite.camera...."), which "visionsuite" below already forbids,
|
||||
# so there is nothing separate to add for it.
|
||||
FORBIDDEN = {
|
||||
"visionsuite", "pm2d", "dxf_compare", "torch",
|
||||
"src.vision", "src.vision_worker",
|
||||
}
|
||||
|
||||
|
||||
def _imported_modules(source: Path) -> set[str]:
|
||||
"""Full dotted module paths this file imports - not just their first
|
||||
segment. `from src.vision.runner import ...` must be checked against
|
||||
"src.vision.runner" (inside the forbidden "src.vision"), not against
|
||||
"src" alone, which is never forbidden since the backend imports itself
|
||||
constantly.
|
||||
"""
|
||||
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
|
||||
modules: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
modules.update(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||
modules.add(node.module)
|
||||
return modules
|
||||
|
||||
|
||||
def _forbidden_hits(modules: set[str]) -> set[str]:
|
||||
"""Which FORBIDDEN entries a set of imported module paths triggers.
|
||||
|
||||
A module counts as forbidden if it equals a forbidden entry exactly, or
|
||||
lives inside it ("src.vision.runner" is inside "src.vision").
|
||||
"""
|
||||
hits: set[str] = set()
|
||||
for module in modules:
|
||||
for entry in FORBIDDEN:
|
||||
if module == entry or module.startswith(entry + "."):
|
||||
hits.add(entry)
|
||||
return hits
|
||||
|
||||
|
||||
def test_the_server_never_imports_visionsuite():
|
||||
offenders = []
|
||||
for source in BACKEND.rglob("*.py"):
|
||||
if "tests" in source.parts or "migrations" in source.parts:
|
||||
continue
|
||||
forbidden = _forbidden_hits(_imported_modules(source))
|
||||
if forbidden:
|
||||
offenders.append(f"{source.relative_to(BACKEND)}: {sorted(forbidden)}")
|
||||
|
||||
assert offenders == [], (
|
||||
"il server deve restare cieco alla visione; usa il worker via HTTP:\n"
|
||||
+ "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_the_submodule_is_pinned():
|
||||
"""Server e stazione devono montare lo stesso commit: qui c'è la fonte."""
|
||||
gitmodules = Path(__file__).resolve().parents[3] / ".gitmodules"
|
||||
assert gitmodules.exists(), "vendor/visionsuite non è un sottomodulo"
|
||||
assert "vendor/visionsuite" in gitmodules.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_a_full_module_path_import_is_caught_not_just_its_root(tmp_path):
|
||||
"""M2: `from src.vision.runner import ...` inside src/backend/** must be
|
||||
caught. Checking only the first dotted segment ("src") would miss it -
|
||||
"src" itself is never forbidden, the backend imports itself all the time.
|
||||
This is the blind spot the reviewer found: same repository, no install
|
||||
needed, the most tempting shortcut of all.
|
||||
"""
|
||||
offender = tmp_path / "sneaky.py"
|
||||
offender.write_text("from src.vision.runner import run_graph\n")
|
||||
assert _forbidden_hits(_imported_modules(offender)) == {"src.vision"}
|
||||
|
||||
|
||||
def test_a_vision_worker_import_is_also_caught(tmp_path):
|
||||
offender = tmp_path / "sneaky.py"
|
||||
offender.write_text("import src.vision_worker.main\n")
|
||||
assert _forbidden_hits(_imported_modules(offender)) == {"src.vision_worker"}
|
||||
|
||||
|
||||
def test_an_unrelated_src_backend_import_is_not_caught(tmp_path):
|
||||
"""The fix must not turn every self-import of the backend into an
|
||||
offender - only the vision subtrees are forbidden."""
|
||||
innocent = tmp_path / "innocent.py"
|
||||
innocent.write_text("from src.backend.config import settings\n")
|
||||
assert _forbidden_hits(_imported_modules(innocent)) == set()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""C1: the vision graph must survive copy-on-write, and creation must save it.
|
||||
|
||||
The spec assumed versioning was free for `vision_json`/`vision_output` - "un
|
||||
campo in piu nella copia, non una logica nuova". `_copy_tasks_to_version`
|
||||
copies an explicit field list that omitted both fields, so the first edit to
|
||||
a recipe that already has measurements silently drops the camera graph from
|
||||
the new version. The creation paths (`POST /api/recipes/{id}/tasks` and
|
||||
`POST /api/tasks/{id}/subtasks`) have the same shape of bug: `TaskCreate` and
|
||||
`SubtaskCreate` declare the fields, but the routers never pass them to the
|
||||
ORM constructors, so the API answers 200 and silently does not store them.
|
||||
|
||||
Each test file here reads on its own - the same idiom `test_vision_graph.py`
|
||||
and `test_recipes.py` already use.
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.user import User
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def _seed_measurement(db: AsyncSession, recipe_id: int, user_id: int) -> None:
|
||||
"""Give the current version a measurement, so an update takes the
|
||||
copy-on-write path instead of updating in place."""
|
||||
version_id = (await db.execute(
|
||||
select(RecipeVersion.id).where(
|
||||
RecipeVersion.recipe_id == recipe_id,
|
||||
RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)).scalar_one()
|
||||
subtask_id = (await db.execute(
|
||||
select(RecipeSubtask.id)
|
||||
.join(RecipeSubtask.task)
|
||||
.where(RecipeSubtask.task.has(version_id=version_id))
|
||||
)).scalar_one()
|
||||
db.add(Measurement(
|
||||
subtask_id=subtask_id,
|
||||
version_id=version_id,
|
||||
measured_by=user_id,
|
||||
value=10.0,
|
||||
pass_fail="pass",
|
||||
deviation=0.0,
|
||||
input_method="manual",
|
||||
))
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def _current_task_and_subtask(db: AsyncSession, recipe_id: int):
|
||||
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()
|
||||
subtask = (await db.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
return version, task, subtask
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_on_write_preserves_the_vision_graph(
|
||||
client: AsyncClient, db_session: AsyncSession, maker_user: User,
|
||||
):
|
||||
"""A recipe edit that triggers copy-on-write must not drop the camera graph."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, subtask = await _current_task_and_subtask(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.flush()
|
||||
|
||||
# A measurement on v1 forces the next PUT onto the copy-on-write path.
|
||||
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||
db_session.expunge_all()
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"change_notes": "Edit that must not drop the graph"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
db_session.expunge_all()
|
||||
|
||||
version, new_task, new_subtask = await _current_task_and_subtask(db_session, recipe.id)
|
||||
assert version.version_number == 2, "the edit should have created v2"
|
||||
assert new_task.vision_json == {"schema_version": 1, "name": "p", "tools": []}
|
||||
assert new_subtask.vision_output == "t1.diameter"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creating_a_task_persists_its_vision_graph(
|
||||
client: AsyncClient, db_session: AsyncSession, maker_user: User,
|
||||
):
|
||||
"""POST /api/recipes/{id}/tasks must store vision_json and vision_output,
|
||||
not just accept and echo them back."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"title": "Camera task",
|
||||
"task_type": "camera_measure",
|
||||
"vision_json": {"schema_version": 1, "name": "p", "tools": []},
|
||||
"subtasks": [
|
||||
{
|
||||
"marker_number": 1,
|
||||
"description": "Diameter",
|
||||
"nominal": 10.0,
|
||||
"vision_output": "t1.diameter",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["vision_json"] == {"schema_version": 1, "name": "p", "tools": []}
|
||||
assert body["subtasks"][0]["vision_output"] == "t1.diameter"
|
||||
|
||||
# Reload from the database - the response reflects the ORM object right
|
||||
# after flush, so this catches a constructor that dropped the field.
|
||||
db_session.expunge_all()
|
||||
task = (await db_session.execute(
|
||||
select(RecipeTask).where(RecipeTask.id == body["id"])
|
||||
)).scalar_one()
|
||||
sub = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().one()
|
||||
assert task.vision_json == {"schema_version": 1, "name": "p", "tools": []}
|
||||
assert sub.vision_output == "t1.diameter"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creating_a_subtask_persists_its_vision_output(
|
||||
client: AsyncClient, db_session: AsyncSession, maker_user: User,
|
||||
):
|
||||
"""POST /api/tasks/{id}/subtasks (the standalone endpoint) must also store it."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/tasks/{task.id}/subtasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"marker_number": 2,
|
||||
"description": "Radius",
|
||||
"nominal": 5.0,
|
||||
"vision_output": "t1.radius",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["vision_output"] == "t1.radius"
|
||||
|
||||
db_session.expunge_all()
|
||||
sub = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.marker_number == 2)
|
||||
)).scalar_one()
|
||||
assert sub.vision_output == "t1.radius"
|
||||
@@ -0,0 +1,329 @@
|
||||
"""A quote from a camera is a measurement like any other: same verdict, same gate."""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
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 VisionResult
|
||||
from src.backend.services import measurement_service
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
GOOD = 10.0 # inside every limit of the fixture quote
|
||||
OUT = 99.0 # far outside
|
||||
|
||||
|
||||
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):
|
||||
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
|
||||
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 two_vision_quotes(db_session, recipe_id: int):
|
||||
"""The fixture recipe's task, with a second quote mapped to a vision output.
|
||||
|
||||
Same shape as `test_out_of_tolerance.py::_two_quotes`, adapted so both
|
||||
quotes belong to a single camera acquisition instead of two separate
|
||||
manual readings.
|
||||
"""
|
||||
version, task, first = await recipe_parts(db_session, recipe_id)
|
||||
first.vision_output = "t1.diameter"
|
||||
|
||||
second = RecipeSubtask(
|
||||
task_id=task.id, marker_number=2, description="Second quote",
|
||||
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||
vision_output="t1.radius",
|
||||
)
|
||||
db_session.add(second)
|
||||
task.task_type = "camera_measure"
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
await db_session.commit()
|
||||
await db_session.refresh(second)
|
||||
return version, task, first, second
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_says(monkeypatch):
|
||||
"""The worker does not run in tests: its response is faked here."""
|
||||
def _install(outputs: dict, failures: list | None = None):
|
||||
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||
return {
|
||||
"outputs": outputs,
|
||||
"failures": failures or [],
|
||||
"engine_version": "a" * 40,
|
||||
"duration_ms": 12,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"src.backend.services.vision_service._call_worker", fake_call,
|
||||
)
|
||||
return _install
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_camera_quote_becomes_a_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)
|
||||
task.task_type = "camera_measure"
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
subtask.vision_output = "t1.diameter"
|
||||
# The fixture creates the quote with nominal 10 and tight limits around it.
|
||||
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", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id), "lot_number": "L1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
saved = (await db_session.execute(
|
||||
select(Measurement).where(Measurement.subtask_id == subtask.id)
|
||||
)).scalars().all()
|
||||
assert len(saved) == 1
|
||||
assert saved[0].pass_fail == "pass"
|
||||
assert saved[0].input_method == "camera"
|
||||
assert saved[0].vision_result_id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_execution_records_which_engine_produced_it(
|
||||
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)
|
||||
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)})
|
||||
|
||||
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": "L1"},
|
||||
)
|
||||
|
||||
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
assert result.engine_version == "a" * 40
|
||||
assert result.executed_on == "server"
|
||||
assert result.graph_snapshot == task.vision_json
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_missing_output_names_itself(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""No half measurement: if an output is missing, the error names it."""
|
||||
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.radius": 5.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": "L1"},
|
||||
)
|
||||
|
||||
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,
|
||||
):
|
||||
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"
|
||||
await db_session.commit()
|
||||
worker_says({})
|
||||
|
||||
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 "graph" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ruling R10: the gate is evaluated once per acquisition, not once per quote.
|
||||
# A camera acquisition produces every quote of the task at the same instant -
|
||||
# there is no "moving on" between them, so the sequential gate must not fire
|
||||
# inside the batch. It still has to fire between two separate acquisitions.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_non_last_out_of_tolerance_quote_does_not_break_the_batch(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""The quote evaluated first fails; the one after it must still be saved."""
|
||||
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": OUT, "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 == 200
|
||||
by_subtask = {
|
||||
m.subtask_id: m.pass_fail
|
||||
for m in (await db_session.execute(select(Measurement))).scalars().all()
|
||||
}
|
||||
assert by_subtask == {first.id: "fail", second.id: "pass"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_pending_fail_outside_the_batch_still_blocks(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""A fail left over from something else this camera task does not cover."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
|
||||
# A third quote on the same task, not part of this acquisition (no vision
|
||||
# output), already failed and is waiting on a supervisor.
|
||||
third = RecipeSubtask(
|
||||
task_id=task.id, marker_number=3, description="Not a vision quote",
|
||||
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||
)
|
||||
db_session.add(third)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(third)
|
||||
await measurement_service.save_measurement(
|
||||
db_session, subtask_id=third.id, version_id=version.id,
|
||||
measured_by=measurement_tec_user.id, value=OUT,
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
worker_says({"t1.diameter": GOOD, "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 == 409
|
||||
assert "supervisor" in response.json()["detail"].lower()
|
||||
assert (await db_session.execute(
|
||||
select(Measurement).where(Measurement.subtask_id.in_([first.id, second.id]))
|
||||
)).scalars().all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_pending_fail_blocks_before_the_worker_is_called(
|
||||
client, db_session, measurement_tec_user, monkeypatch,
|
||||
):
|
||||
"""M3: a blocked operator must not burn a whole vision execution to learn
|
||||
they are blocked. The gate is checked before the worker runs, not after."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
|
||||
third = RecipeSubtask(
|
||||
task_id=task.id, marker_number=3, description="Not a vision quote",
|
||||
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||
)
|
||||
db_session.add(third)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(third)
|
||||
await measurement_service.save_measurement(
|
||||
db_session, subtask_id=third.id, version_id=version.id,
|
||||
measured_by=measurement_tec_user.id, value=OUT,
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict:
|
||||
raise AssertionError("the worker must not run while the operator is blocked")
|
||||
monkeypatch.setattr(
|
||||
"src.backend.services.vision_service._call_worker", worker_must_not_be_called,
|
||||
)
|
||||
|
||||
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
|
||||
assert "supervisor" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_new_acquisition_can_remeasure_its_own_pending_quote(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""Re-shooting the same task is the way out, same as re-measuring by hand."""
|
||||
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": OUT, "t1.radius": GOOD})
|
||||
first_shot = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p1.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
assert first_shot.status_code == 200
|
||||
|
||||
# first.id is now pending authorisation - but it belongs to this same
|
||||
# task, so a new acquisition of it is not "moving on".
|
||||
worker_says({"t1.diameter": GOOD, "t1.radius": GOOD})
|
||||
second_shot = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p2.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
assert second_shot.status_code == 200
|
||||
|
||||
readings = (await db_session.execute(
|
||||
select(Measurement)
|
||||
.where(Measurement.subtask_id == first.id)
|
||||
.order_by(Measurement.id)
|
||||
)).scalars().all()
|
||||
assert [m.pass_fail for m in readings] == ["fail", "pass"]
|
||||
|
||||
assert await measurement_service.pending_authorisation(
|
||||
db_session, version.id, measurement_tec_user.id,
|
||||
) is None
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Il grafo di visione vive sul task, e la quota sa quale uscita la riempie."""
|
||||
import pytest
|
||||
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.tests.conftest import create_test_recipe
|
||||
|
||||
|
||||
async def recipe_parts(db_session, recipe_id: int):
|
||||
"""`create_test_recipe` restituisce la sola ricetta: qui si scompone.
|
||||
|
||||
Versione corrente, il suo unico task, la sua prima quota. È l'idioma che
|
||||
`test_out_of_tolerance.py` usa già, ripetuto perché ogni file di prova si
|
||||
legge da solo.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_carries_a_vision_graph(db_session, maker_user):
|
||||
"""Il grafo si salva e si rilegge identico: il server non lo interpreta."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="VIS-1")
|
||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||
graph = {
|
||||
"schema_version": 1,
|
||||
"name": "diametro del foro",
|
||||
"tools": [{"id": "t1", "kind": "DetectCircle", "name": "foro"}],
|
||||
}
|
||||
task.vision_json = graph
|
||||
task.task_type = "camera_measure"
|
||||
await db_session.flush()
|
||||
db_session.expunge_all()
|
||||
|
||||
reloaded = (await db_session.execute(
|
||||
select(RecipeTask).where(RecipeTask.id == task.id)
|
||||
)).scalar_one()
|
||||
assert reloaded.vision_json == graph
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subtask_names_the_output_that_fills_it(db_session, maker_user):
|
||||
"""La quota dichiara quale uscita del grafo le compete."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="VIS-2")
|
||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||
subtask.vision_output = "t1.diameter"
|
||||
await db_session.flush()
|
||||
db_session.expunge_all()
|
||||
|
||||
reloaded = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask.id)
|
||||
)).scalar_one()
|
||||
assert reloaded.vision_output == "t1.diameter"
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,110 @@
|
||||
"""The Maker composes at a desk: image saved, graph tried, no measurement."""
|
||||
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.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.vision import VisionReferenceImage
|
||||
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):
|
||||
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
|
||||
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
|
||||
|
||||
|
||||
@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": "b" * 40, "duration_ms": 7}
|
||||
monkeypatch.setattr(
|
||||
"src.backend.services.vision_service._call_worker", fake_call,
|
||||
)
|
||||
return _install
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def redirect_uploads(monkeypatch, tmp_path):
|
||||
"""Keep reference images out of the real repo tree during tests.
|
||||
|
||||
`settings.upload_path` is `parents[2] / self.upload_dir`, and pathlib
|
||||
replaces the left operand of `/` when the right one is absolute - so
|
||||
redirecting the bare `upload_dir` setting to pytest's absolute `tmp_path`
|
||||
is enough to redirect the resolved path too.
|
||||
"""
|
||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_reference_image_keeps_its_provenance(
|
||||
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()
|
||||
|
||||
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), "note": "sample part"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
saved = (await db_session.execute(
|
||||
select(VisionReferenceImage)
|
||||
)).scalars().one()
|
||||
assert saved.task_id == task.id
|
||||
assert saved.note == "sample part"
|
||||
assert saved.acquired_at is not None
|
||||
assert saved.path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_runs_the_graph_and_saves_no_measurement(
|
||||
client, db_session, maker_user, worker_says,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_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": 42.0})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/preview",
|
||||
headers=auth_headers(maker_user),
|
||||
files={"image": ("ref.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["outputs"]["t1.diameter"] == 42.0
|
||||
# This is the whole point of this test: composing does not measure.
|
||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||
@@ -0,0 +1,190 @@
|
||||
"""I3: the three vision endpoints must not accept an unbounded or wrong-typed
|
||||
upload. `/execute`, `/preview` and `/reference-images` read the whole image
|
||||
into memory with no size cap, and `/reference-images` wrote whatever arrived
|
||||
to disk with a `.png` extension regardless of its real type - a PDF is not
|
||||
something a vision graph can measure.
|
||||
|
||||
Reuses the project's existing convention (`validate_file_size`,
|
||||
`ALLOWED_IMAGE_TYPES` from `files.py`) instead of inventing a second one.
|
||||
"""
|
||||
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.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def redirect_uploads(monkeypatch, tmp_path):
|
||||
"""Keep the reference image this file stores out of the real repo tree.
|
||||
|
||||
The same guard `test_vision_reference.py` carries: without it, the test that
|
||||
proves a jpeg is stored with its own extension leaves a real file under
|
||||
`uploads/vision/reference/`, so every suite run dirties the working tree.
|
||||
|
||||
`settings.upload_path` is `parents[2] / self.upload_dir`, and pathlib
|
||||
replaces the left operand of `/` when the right one is absolute - so
|
||||
redirecting the bare `upload_dir` setting is enough to redirect the
|
||||
resolved path too.
|
||||
"""
|
||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||
|
||||
|
||||
def a_png() -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def a_jpeg() -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (64, 64), color=0).save(buffer, format="JPEG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
async def recipe_parts(db_session, recipe_id: int):
|
||||
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
|
||||
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
|
||||
|
||||
|
||||
@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": "c" * 40, "duration_ms": 5}
|
||||
monkeypatch.setattr(
|
||||
"src.backend.services.vision_service._call_worker", fake_call,
|
||||
)
|
||||
return _install
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_a_non_image_upload(
|
||||
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)
|
||||
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.pdf", b"%PDF-1.4 not an image", "application/pdf")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
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,
|
||||
):
|
||||
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)})
|
||||
monkeypatch.setattr(settings, "max_upload_size_mb", 0)
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", a_png(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert (await db_session.execute(select(VisionResult))).scalars().all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_rejects_a_non_image_upload(
|
||||
client, db_session, maker_user, worker_says,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_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": []}
|
||||
await db_session.commit()
|
||||
worker_says({"t1.diameter": 10.0})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/preview",
|
||||
headers=auth_headers(maker_user),
|
||||
files={"image": ("p.pdf", b"%PDF-1.4 not an image", "application/pdf")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_images_rejects_a_non_image_upload(
|
||||
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()
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/reference-images",
|
||||
headers=auth_headers(maker_user),
|
||||
files={"image": ("p.pdf", b"%PDF-1.4 not an image", "application/pdf")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert (await db_session.execute(
|
||||
select(VisionReferenceImage)
|
||||
)).scalars().all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_image_keeps_the_extension_matching_its_real_type(
|
||||
client, db_session, maker_user,
|
||||
):
|
||||
"""A jpeg upload must not be written to disk as `<uuid>.png`."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/reference-images",
|
||||
headers=auth_headers(maker_user),
|
||||
files={"image": ("p.jpg", a_jpeg(), "image/jpeg")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
saved = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
|
||||
assert saved.path.endswith(".jpg")
|
||||
assert (settings.upload_path / saved.path).exists()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""I2: a broken worker must answer with a named status, never a naked 500.
|
||||
|
||||
`_call_worker` mapped a non-200 HTTP response to 502, but let
|
||||
`httpx.ConnectError` (worker stopped) and `httpx.ReadTimeout` (job past the
|
||||
120s budget) propagate unhandled - with no exception handler in main.py that
|
||||
turns those into anything but FastAPI's generic 500. It also read
|
||||
`report["outputs"]`, `report["failures"]` and `report["engine_version"]`
|
||||
without checking the response had that shape at all, so a proxy answering 200
|
||||
with something else (HTML, an empty body) would raise a bare KeyError instead
|
||||
of a diagnosable error.
|
||||
|
||||
The spec's edge-case table: "il worker isola il guasto, l'API resta in
|
||||
piedi, il task riporta l'errore" - the worker's fault must not become the
|
||||
API's fault.
|
||||
"""
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.backend.services.vision_service import _call_worker
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Stands in for httpx.Response: only what `_call_worker` touches."""
|
||||
|
||||
def __init__(self, status_code: int, body=..., text: str = "", raise_on_json: bool = False):
|
||||
self.status_code = status_code
|
||||
self._body = body
|
||||
self.text = text
|
||||
self._raise_on_json = raise_on_json
|
||||
|
||||
def json(self):
|
||||
if self._raise_on_json:
|
||||
raise ValueError("not JSON")
|
||||
return self._body
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
"""Stands in for httpx.AsyncClient: post() does whatever the test wants."""
|
||||
|
||||
def __init__(self, *, response=None, raises=None):
|
||||
self._response = response
|
||||
self._raises = raises
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
async def post(self, *args, **kwargs):
|
||||
if self._raises is not None:
|
||||
raise self._raises
|
||||
return self._response
|
||||
|
||||
|
||||
def _install(monkeypatch, *, response=None, raises=None):
|
||||
monkeypatch.setattr(
|
||||
"src.backend.services.vision_service.httpx.AsyncClient",
|
||||
lambda *a, **k: _FakeAsyncClient(response=response, raises=raises),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_stopped_worker_answers_502_not_a_naked_500(monkeypatch):
|
||||
_install(monkeypatch, raises=httpx.ConnectError("Connection refused"))
|
||||
|
||||
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_job_past_the_time_budget_answers_504(monkeypatch):
|
||||
_install(monkeypatch, raises=httpx.ReadTimeout("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()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_response_missing_the_expected_keys_is_reported_not_raised_raw(monkeypatch):
|
||||
"""A proxy answering 200 with an unexpected shape must not KeyError."""
|
||||
_install(monkeypatch, response=_FakeResponse(200, body={"outputs": {}}))
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||
|
||||
assert excinfo.value.status_code == 502
|
||||
assert "malformed" in excinfo.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_response_that_is_not_a_json_object_is_reported(monkeypatch):
|
||||
"""200 with HTML or a bare list, say - not a dict at all."""
|
||||
_install(monkeypatch, response=_FakeResponse(200, body=["not", "a", "dict"]))
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||
|
||||
assert excinfo.value.status_code == 502
|
||||
assert "malformed" in excinfo.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_well_formed_response_still_passes_through(monkeypatch):
|
||||
body = {"outputs": {"t1.diameter": 10.0}, "failures": [], "engine_version": "a" * 40}
|
||||
_install(monkeypatch, response=_FakeResponse(200, body=body))
|
||||
|
||||
report = await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||
|
||||
assert report == body
|
||||
@@ -1,17 +1,44 @@
|
||||
"""TieMeasureFlow Client - Flask Entry Point."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import date
|
||||
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 markupsafe import Markup
|
||||
from markupsafe import Markup, escape
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from config import Config
|
||||
|
||||
# **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.
|
||||
_BOLD_RX = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
|
||||
|
||||
# Punto 12. Nothing loads from outside: every library ships with the install
|
||||
# (static/vendor/), because the shop floor network has no way out and a page that
|
||||
# waits for a CDN there is a white screen, not a slow one.
|
||||
#
|
||||
# unsafe-inline the templates carry their Alpine components inline
|
||||
# unsafe-eval Alpine 3 and Plotly both compile expressions at runtime
|
||||
# blob: PDF.js runs its worker from a blob, Fabric exports canvases
|
||||
# data: thumbnails and canvas exports are inlined
|
||||
CSP = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"font-src 'self'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self'; "
|
||||
"worker-src 'self' blob:; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
|
||||
|
||||
def get_locale():
|
||||
"""Get user's preferred language from session or Accept-Language header."""
|
||||
@@ -74,6 +101,24 @@ def create_app() -> Flask:
|
||||
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.
|
||||
|
||||
Whoever writes a recipe pastes from the PDF of the technical sheet, and the
|
||||
text used to arrive flattened. Two conventions carry it: a blank line is a
|
||||
line break, **like this** is bold.
|
||||
|
||||
No HTML is ever stored or trusted - the text is escaped first and the only
|
||||
tags in the result are the ones produced here. That is the sanitisation:
|
||||
there is nothing to sanitise, because nothing is accepted.
|
||||
"""
|
||||
if not value:
|
||||
return Markup("")
|
||||
escaped = str(escape(str(value)))
|
||||
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
|
||||
return Markup(bolded.replace("\n", "<br>"))
|
||||
|
||||
@app.template_filter("tojson_attr")
|
||||
def tojson_attr_filter(value):
|
||||
"""JSON encode safe for HTML attributes (x-data, etc.).
|
||||
@@ -92,6 +137,25 @@ def create_app() -> Flask:
|
||||
)
|
||||
return Markup(rv)
|
||||
|
||||
@app.after_request
|
||||
def security_headers(response):
|
||||
"""Same-origin only, on the pages the browser actually loads.
|
||||
|
||||
The policy existed on the backend, which serves the API; the HTML comes
|
||||
from here and carried no policy at all. So the rule that says «nothing
|
||||
from outside» was written where it could not be broken and absent where
|
||||
it could.
|
||||
|
||||
It is also the guard that makes point 12 stay done: a CDN tag added to a
|
||||
template months from now is refused by the browser here, at a desk with a
|
||||
network, instead of on a shop floor that has none.
|
||||
"""
|
||||
response.headers.setdefault("Content-Security-Policy", CSP)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
return response
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
"""Inject global variables into all templates."""
|
||||
|
||||
@@ -119,6 +119,112 @@ def select_recipe():
|
||||
)
|
||||
|
||||
|
||||
def _remember_traceability() -> tuple[str, str]:
|
||||
"""Carry lot and serial from the query string into the session."""
|
||||
lot_number = request.args.get("lot_number", session.get("lot_number", ""))
|
||||
serial_number = request.args.get(
|
||||
"serial_number", session.get("serial_number", ""),
|
||||
)
|
||||
if lot_number:
|
||||
session["lot_number"] = lot_number
|
||||
if serial_number:
|
||||
session["serial_number"] = serial_number
|
||||
return lot_number, serial_number
|
||||
|
||||
|
||||
def _missing_traceability(recipe: dict, lot: str, serial: str) -> list[str]:
|
||||
"""The compulsory fields this recipe is still owed."""
|
||||
missing = []
|
||||
if recipe.get("requires_lot") and not (lot or "").strip():
|
||||
missing.append(_("lotto"))
|
||||
if recipe.get("requires_serial") and not (serial or "").strip():
|
||||
missing.append(_("seriale"))
|
||||
return missing
|
||||
|
||||
|
||||
def _ordered_tasks(recipe_id: int) -> list[dict] | None:
|
||||
"""Tasks of a recipe's current version in execution order, or None on error."""
|
||||
resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return None
|
||||
tasks = resp if isinstance(resp, list) else resp.get("items", [])
|
||||
return sorted(tasks, key=lambda t: (t.get("order_index", 0), t.get("id", 0)))
|
||||
|
||||
|
||||
def _task_progress(version_id, run_id) -> dict[int, dict]:
|
||||
"""Quotes already taken per measurement task, keyed by task id.
|
||||
|
||||
Never fatal: a list that cannot say which task was left half done is still a
|
||||
usable list, and losing the whole page over a badge would be the worse trade.
|
||||
"""
|
||||
if not version_id:
|
||||
return {}
|
||||
params = {"version_id": version_id}
|
||||
if run_id:
|
||||
params["production_run_id"] = run_id
|
||||
resp = api_client.get("/api/measurements/task-progress", params=params)
|
||||
if not isinstance(resp, dict) or resp.get("error"):
|
||||
return {}
|
||||
return {row["task_id"]: row for row in resp.get("tasks", [])}
|
||||
|
||||
|
||||
def _current_run_id() -> int | None:
|
||||
"""The production open at this station, if there is one."""
|
||||
station_code, _overridden = _current_station()
|
||||
if not station_code:
|
||||
return None
|
||||
# The endpoint answers with the run itself, or null when nothing is running.
|
||||
resp = api_client.get(
|
||||
"/api/production-runs/current", params={"station_code": station_code},
|
||||
)
|
||||
if not isinstance(resp, dict) or resp.get("error"):
|
||||
return None
|
||||
return resp.get("id")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: start the guided sequence
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/start/<int:recipe_id>")
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def start_sequence(recipe_id: int):
|
||||
"""Open the first task of a recipe and let the operator walk the sequence.
|
||||
|
||||
Choosing a recipe used to land on the full task list, which asked the operator
|
||||
to pick a starting point before they had done anything. The sequence is the
|
||||
normal way through; the list is still there, one level down, for when they need
|
||||
to see the whole of it.
|
||||
"""
|
||||
lot_number, serial_number = _remember_traceability()
|
||||
|
||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
if recipe_resp.get("error"):
|
||||
flash(
|
||||
_("Ricetta non trovata: %(detail)s",
|
||||
detail=recipe_resp.get("detail", "")),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
|
||||
missing = _missing_traceability(recipe_resp, lot_number, serial_number)
|
||||
if missing:
|
||||
flash(
|
||||
_("Compila prima: %(fields)s", fields=", ".join(missing)),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for(
|
||||
"measure.select_recipe", recipe=recipe_resp.get("code", ""),
|
||||
))
|
||||
|
||||
tasks = _ordered_tasks(recipe_id)
|
||||
if not tasks:
|
||||
flash(_("Questa ricetta non ha ancora task definiti."), "error")
|
||||
return redirect(url_for("measure.task_list", recipe_id=recipe_id))
|
||||
|
||||
return redirect(url_for("measure.task_execute", task_id=tasks[0]["id"]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Task list for a recipe
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -127,17 +233,7 @@ def select_recipe():
|
||||
@role_required("MeasurementTec")
|
||||
def task_list(recipe_id: int):
|
||||
"""Task list for selected recipe."""
|
||||
# Persist lot/serial from query params into session
|
||||
lot_number = request.args.get(
|
||||
"lot_number", session.get("lot_number", ""),
|
||||
)
|
||||
serial_number = request.args.get(
|
||||
"serial_number", session.get("serial_number", ""),
|
||||
)
|
||||
if lot_number:
|
||||
session["lot_number"] = lot_number
|
||||
if serial_number:
|
||||
session["serial_number"] = serial_number
|
||||
lot_number, serial_number = _remember_traceability()
|
||||
|
||||
# Load recipe details
|
||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
@@ -149,22 +245,19 @@ def task_list(recipe_id: int):
|
||||
)
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
|
||||
# Load tasks for this recipe
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, dict) and tasks_resp.get("error"):
|
||||
flash(
|
||||
_("Errore nel caricamento dei task: %(detail)s",
|
||||
detail=tasks_resp.get("detail", "")),
|
||||
"error",
|
||||
)
|
||||
tasks = _ordered_tasks(recipe_id)
|
||||
if tasks is None:
|
||||
flash(_("Errore nel caricamento dei task."), "error")
|
||||
tasks = []
|
||||
else:
|
||||
tasks = tasks_resp if isinstance(tasks_resp, list) else tasks_resp.get("items", [])
|
||||
|
||||
version = recipe_resp.get("current_version") or {}
|
||||
progress = _task_progress(version.get("id"), _current_run_id())
|
||||
|
||||
return render_template(
|
||||
"measure/task_list.html",
|
||||
recipe=recipe_resp,
|
||||
tasks=tasks,
|
||||
progress=progress,
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
)
|
||||
@@ -195,6 +288,9 @@ def task_execute(task_id: int):
|
||||
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
|
||||
# not silently take the keypad away from the operator.
|
||||
allow_manual_input = True
|
||||
if recipe_id:
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, list):
|
||||
@@ -203,6 +299,7 @@ def task_execute(task_id: int):
|
||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
if not recipe_resp.get("error"):
|
||||
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
||||
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
|
||||
|
||||
return render_template(
|
||||
"measure/task_execute.html",
|
||||
@@ -211,6 +308,7 @@ def task_execute(task_id: int):
|
||||
serial_number=serial_number,
|
||||
all_task_ids=all_task_ids,
|
||||
measurement_interval_minutes=measurement_interval_minutes,
|
||||
allow_manual_input=allow_manual_input,
|
||||
)
|
||||
|
||||
|
||||
@@ -392,31 +490,53 @@ def save_measurement():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Validate supervisor credentials (AJAX)
|
||||
# Routes: Out-of-tolerance authorisation (point 5)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/validate-supervisor", methods=["POST"])
|
||||
@measure_bp.route("/api/measurements/pending-authorisation", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def validate_supervisor():
|
||||
"""Validate supervisor (capoturno) credentials for out-of-tolerance authorization."""
|
||||
def api_pending_authorisation():
|
||||
"""Proxy: the out-of-tolerance measurement holding this operator up, or null.
|
||||
|
||||
Asked when the measurement screen loads, so reloading the page finds the same
|
||||
block the server enforces rather than a clean slate.
|
||||
"""
|
||||
params = {"version_id": request.args.get("version_id")}
|
||||
run_id = request.args.get("production_run_id")
|
||||
if run_id:
|
||||
params["production_run_id"] = run_id
|
||||
|
||||
resp = api_client.get("/api/measurements/pending-authorisation", params=params)
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_authorise_measurement(measurement_id: int):
|
||||
"""Proxy: the capoturno lets an out-of-tolerance value stand.
|
||||
|
||||
The credentials go to the endpoint that records the approval on the
|
||||
measurement. Checking them separately, as this used to, left the approval
|
||||
written down nowhere and nothing depending on it.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = data.get("username", "").strip()
|
||||
password = data.get("password", "")
|
||||
|
||||
username = (data.get("supervisor_username") or "").strip()
|
||||
password = data.get("supervisor_password") or ""
|
||||
if not username or not password:
|
||||
return jsonify({"error": True, "detail": _("Username e password richiesti")}), 400
|
||||
return jsonify({
|
||||
"error": True, "detail": _("Username e password richiesti"),
|
||||
}), 400
|
||||
|
||||
resp = api_client.post("/api/auth/login", data={"username": username, "password": password})
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify({"error": True, "detail": _("Credenziali non valide")}), 401
|
||||
|
||||
user = resp.get("user", {})
|
||||
is_supervisor = "Supervisor" in (user.get("roles") or [])
|
||||
if not (is_supervisor or user.get("is_admin")):
|
||||
return jsonify({"error": True, "detail": _("Utente non autorizzato (richiesto capoturno)")}), 403
|
||||
|
||||
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
|
||||
resp = api_client.post(
|
||||
f"/api/measurements/{measurement_id}/authorise",
|
||||
data={"supervisor_username": username, "supervisor_password": password},
|
||||
)
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -124,10 +124,66 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Page shell — one frame for every view
|
||||
|
||||
Punto 14. Ogni schermata si disegnava la propria larghezza: sette valori
|
||||
diversi fra le viste, nessuno allineato alla navbar, che sta a 80rem su
|
||||
tutte. Passando da un menu all'altro il blocco di contenuto cambiava
|
||||
larghezza e si spostava rispetto alla barra sopra di sé.
|
||||
|
||||
Qui la cornice è una sola e coincide con quella della navbar. Le pagine
|
||||
che hanno bisogno di una colonna stretta - i moduli - la stringono al loro
|
||||
interno: il bordo della pagina resta dov'è, cambia solo il contenuto.
|
||||
============================================================ */
|
||||
|
||||
.tmf-page {
|
||||
width: 100%;
|
||||
max-width: 80rem; /* == max-w-7xl, la stessa della navbar */
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem; /* == px-4 */
|
||||
padding-block: 1.5rem; /* == py-6 */
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.tmf-page { padding-inline: 1.5rem; } /* == sm:px-6 */
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.tmf-page { padding-inline: 2rem; } /* == lg:px-8 */
|
||||
}
|
||||
|
||||
/* I moduli restano su una colonna leggibile: un campo di testo largo 1280px non
|
||||
si compila meglio, si compila peggio. Due larghezze in tutto il prodotto -
|
||||
questa e quella sopra - al posto delle sette di prima. */
|
||||
.tmf-page.tmf-page-narrow {
|
||||
max-width: 56rem; /* == max-w-4xl */
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Custom Scrollbar
|
||||
============================================================ */
|
||||
|
||||
/* Lo spazio della barra di scorrimento è riservato sempre, anche quando la
|
||||
pagina non scorre. Senza, ogni passaggio fra una vista lunga e una corta -
|
||||
e ogni ingresso nella schermata di misura, che blocca lo scorrimento -
|
||||
spostava di 8px tutto il contenuto centrato, navbar compresa. */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* La schermata di misura è alta quanto la finestra vera, non quanto quella
|
||||
teorica: su Android e iOS `100vh` è l'altezza che la pagina avrebbe con la
|
||||
barra dell'indirizzo nascosta, quindi su un tablet il piede - dove stanno
|
||||
«Fine ciclo misura» e il tastierino - finisce sotto il bordo dello schermo, e
|
||||
ricompare quando la barra si ritrae. `100dvh` segue la finestra reale. */
|
||||
@supports (height: 100dvh) {
|
||||
body.h-screen {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Webkit (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* Used for measurement data entry in task_execute.html
|
||||
*/
|
||||
|
||||
function numpad() {
|
||||
function numpad(options) {
|
||||
var opts = options || {};
|
||||
|
||||
return {
|
||||
// State
|
||||
value: '', // String representation of the current value
|
||||
@@ -13,9 +15,16 @@ function numpad() {
|
||||
maxIntDigits: 6, // Maximum integer digits
|
||||
maxDecDigits: 6, // Maximum decimal digits
|
||||
|
||||
/* Whether a value may be typed at all, as the recipe declares it. When false
|
||||
the keypad shows no digits and a value that looks typed is refused here as
|
||||
well as by the server: the caliper is the instrument. Defaults to allowed,
|
||||
so a caller that says nothing gets the behaviour that came before. */
|
||||
allowManual: opts.allowManual !== false,
|
||||
|
||||
// HID burst detection (USB caliper vs manual typing)
|
||||
_lastKeyTime: 0, // Timestamp of last keystroke
|
||||
_burstCount: 0, // Consecutive fast keystrokes
|
||||
_enterWasFast: false, // Enter arrived in the same burst as the digits
|
||||
|
||||
/**
|
||||
* Get the display value with sign
|
||||
@@ -109,6 +118,20 @@ function numpad() {
|
||||
this.value = '';
|
||||
this.negative = false;
|
||||
this.hasDecimal = false;
|
||||
this._enterWasFast = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* How the value in the display got there.
|
||||
*
|
||||
* A wedge caliper sends its digits and the Enter that follows them as one
|
||||
* burst; a person is slower on both. The Enter is the stronger of the two
|
||||
* signals: a short reading like "9.5" is only three keystrokes, too few to
|
||||
* judge by count alone, and used to be filed as typed by hand.
|
||||
*/
|
||||
_classifyInput() {
|
||||
if (this._enterWasFast) return 'usb_caliper';
|
||||
return this._burstCount >= 3 ? 'usb_caliper' : 'manual';
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -118,9 +141,18 @@ function numpad() {
|
||||
if (!this.hasValue) return;
|
||||
|
||||
const val = this.numericValue;
|
||||
const inputMethod = this._classifyInput();
|
||||
|
||||
// Determine input method: 3+ fast keystrokes = USB caliper burst
|
||||
const inputMethod = this._burstCount >= 3 ? 'usb_caliper' : 'manual';
|
||||
// The recipe forbids typing: say so and keep the value on screen rather than
|
||||
// clearing it, so the operator sees what was refused. The server refuses the
|
||||
// same request anyway - this is only the earlier, kinder of the two answers.
|
||||
if (inputMethod === 'manual' && !this.allowManual) {
|
||||
this.$dispatch('numpad-rejected', { reason: 'manual_not_allowed', value: val });
|
||||
this._enterWasFast = false;
|
||||
this._burstCount = 0;
|
||||
this._lastKeyTime = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch custom event for parent component to handle
|
||||
this.$dispatch('numpad-confirm', { value: val, inputMethod: inputMethod });
|
||||
@@ -199,6 +231,12 @@ function numpad() {
|
||||
// Enter - confirm
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Measured before confirming: an Enter that lands within the burst is the
|
||||
// caliper closing its own transmission, not a person reaching for a key.
|
||||
const gap = this._lastKeyTime > 0
|
||||
? performance.now() - this._lastKeyTime
|
||||
: Infinity;
|
||||
this._enterWasFast = gap < 80;
|
||||
this.confirm();
|
||||
}
|
||||
// Minus sign - toggle sign
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Task descriptions: line breaks and bold, and nothing else.
|
||||
*
|
||||
* Whoever writes a recipe pastes from the PDF of the technical sheet, and the text
|
||||
* used to arrive flattened - every line run into the next, every emphasis lost, to
|
||||
* be put back by hand each time. Two conventions carry it now: a newline is a line
|
||||
* break, **like this** is bold.
|
||||
*
|
||||
* No HTML is stored or trusted. The text is escaped first and the only tags in the
|
||||
* result are the ones produced here, so a description that contains <script> shows
|
||||
* those characters and does nothing. The server-side filter (rich_text in app.py)
|
||||
* follows exactly these rules, so a description reads the same whether the page
|
||||
* was rendered by Flask or by Alpine.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/* Escaped text, then the two tags we add ourselves. Order matters: escaping
|
||||
after the substitution would turn our own <strong> into visible text. */
|
||||
function richText(value) {
|
||||
if (!value) return '';
|
||||
return escapeHtml(value)
|
||||
.replace(/\*\*([\s\S]+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
/* Wrap whatever is selected in a textarea in ** **, the way a B button does.
|
||||
Dispatching 'input' is what keeps x-model in step: assigning to value alone
|
||||
changes the DOM and leaves Alpine with the old string. */
|
||||
function wrapSelectionBold(el) {
|
||||
if (!el) return;
|
||||
var start = el.selectionStart;
|
||||
var end = el.selectionEnd;
|
||||
var text = el.value || '';
|
||||
el.value = text.slice(0, start) + '**' + text.slice(start, end) + '**' + text.slice(end);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.focus();
|
||||
// Leave the cursor around the same words, now inside the markers.
|
||||
el.selectionStart = start + 2;
|
||||
el.selectionEnd = end + 2;
|
||||
}
|
||||
|
||||
global.richText = richText;
|
||||
global.wrapSelectionBold = wrapSelectionBold;
|
||||
})(window);
|
||||
+43
-1
@@ -64,7 +64,49 @@ db5ff4db83e580426280e9337a58dc57d3a83784a1b03ad80914651594441d52 fonts/jetbrain
|
||||
```
|
||||
|
||||
Verifica: `sha256sum -c` da questa cartella, oppure
|
||||
`sha256sum *.js fonts/*.woff2`.
|
||||
`sha256sum *.js fonts/*.woff2`. Il confronto è anche un test
|
||||
(`tests/test_offline.py::test_the_shipped_libraries_are_the_ones_that_were_checked`):
|
||||
una libreria sostituita sul posto tiene lo stesso nome e la stessa riga in tabella,
|
||||
e l'impronta è l'unica parte che se ne accorge.
|
||||
|
||||
## La regola che tiene il punto chiuso
|
||||
|
||||
Le copie locali risolvono il problema una volta; quello che lo tiene risolto è la
|
||||
**Content-Security-Policy** servita dal client Flask (`app.py`, `CSP`), che ammette
|
||||
solo l'origine locale. Prima esisteva solo sul backend — cioè sulle risposte API,
|
||||
non sulle pagine che il browser disegna: la regola stava scritta dove non poteva
|
||||
essere infranta e mancava dove poteva.
|
||||
|
||||
Con la policy in vigore, un `<script src="https://cdn…">` aggiunto fra sei mesi viene
|
||||
rifiutato dal browser **alla scrivania**, dove c'è la rete e l'errore si legge in
|
||||
console, invece che in reparto dove la rete non c'è.
|
||||
|
||||
## Tailwind
|
||||
|
||||
Tailwind non sta in questa cartella: è compilato in fase di build dell'immagine
|
||||
(`Dockerfile.frontend`) e il CSS finito viene servito da `static/css/tailwind.css`.
|
||||
Vale però lo stesso vincolo — la versione è fissata a `tailwindcss@3.4.19`. Con
|
||||
`tailwindcss@3` ogni ricostruzione poteva prendere una minor diversa e produrre un
|
||||
foglio di stile diverso senza che nessuno l'avesse validato.
|
||||
|
||||
Nota per l'installazione on-premise: la **costruzione** dell'immagine richiede rete
|
||||
(npm, apt, uv). È l'esecuzione a non richiederla. In reparto va portata l'immagine
|
||||
già costruita, non il repository da compilare sul posto.
|
||||
|
||||
## Una sesta libreria, oggi non caricata
|
||||
|
||||
`static/js/barcode.js` e `templates/components/barcode_scanner.html` implementano lo
|
||||
scanner QR **con la fotocamera** e si appoggiano a `html5-qrcode`, che non è mai
|
||||
caricata da nessuna pagina: il componente non è incluso da nessuna parte e la
|
||||
libreria non è in questa cartella. A runtime non rompe niente — è codice morto.
|
||||
|
||||
La lettura del barcode che l'operatore usa davvero è un campo di testo in
|
||||
`measure/select_recipe.html`: il lettore hardware la digita, e non serve nessuna
|
||||
libreria.
|
||||
|
||||
Se un giorno si accende lo scanner da fotocamera, `html5-qrcode` va portata qui
|
||||
**prima**, altrimenti in reparto quel pulsante mostra «scanner_lib_not_loaded» e
|
||||
basta.
|
||||
|
||||
## Aggiornare una libreria
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
||||
<div class="tmf-page tmf-page-narrow"
|
||||
x-data="systemSettings()">
|
||||
|
||||
<!-- Header -->
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
window.__allRecipes = {{ all_recipes|tojson }};
|
||||
</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
||||
<div class="tmf-page"
|
||||
x-data="stationManagement(window.__stations, window.__allRecipes)">
|
||||
|
||||
<!-- Header -->
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<script>window.__users = {{ users|tojson }};</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
||||
<div class="tmf-page"
|
||||
x-data="userManagement(window.__users)">
|
||||
|
||||
<!-- Header -->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block title %}{{ _('Profilo') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl">
|
||||
<div class="tmf-page tmf-page-narrow">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-3">
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
<!-- Numpad Component - Touch-friendly numeric keypad for measurement input -->
|
||||
{#
|
||||
allow_manual_input comes from the recipe. When it is false the digits are not
|
||||
drawn at all - not hidden with CSS: they are the only unambiguously typed way
|
||||
in, and markup that is merely hidden is still markup that can be shown again.
|
||||
|
||||
The keyboard listener stays either way: that is how the USB caliper arrives, and
|
||||
the component still checks at confirm time, because a wedge and a keyboard are
|
||||
the same device as far as the browser is concerned.
|
||||
#}
|
||||
{% set allow_manual = allow_manual_input if allow_manual_input is defined else true %}
|
||||
<div
|
||||
x-data="numpad()"
|
||||
x-data="numpad({ allowManual: {{ allow_manual|tojson }} })"
|
||||
@keydown.window="handleKeydown($event)"
|
||||
class="numpad-container w-full max-w-sm mx-auto"
|
||||
>
|
||||
@@ -18,6 +28,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not allow_manual %}
|
||||
<!-- Caliper only: no digits, just the corrections and the confirm -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-center gap-2 px-3 py-2 rounded-lg
|
||||
bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800">
|
||||
<svg class="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium text-primary-700 dark:text-primary-300">
|
||||
{{ _('Misura solo da calibro: questa ricetta non ammette valori digitati') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button @click="backspace()" type="button"
|
||||
class="min-h-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>⌫</button>
|
||||
<button @click="clearAll()" type="button"
|
||||
class="min-h-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-measure-fail hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>C</button>
|
||||
<button @click="confirm()" type="button" :disabled="!hasValue"
|
||||
class="min-h-[56px] font-mono text-xl font-semibold rounded-xl border border-primary bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 disabled:hover:bg-primary"
|
||||
>✓</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<!-- Keypad Grid 4x4 -->
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<!-- Row 1: 7 8 9 ⌫ -->
|
||||
@@ -118,4 +154,5 @@
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-primary bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 disabled:hover:bg-primary"
|
||||
>✓</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{#
|
||||
The whole formatting toolbar a task description gets: bold, plus the reminder
|
||||
that a line break is a line break.
|
||||
|
||||
Whoever writes a recipe pastes from the PDF of the technical sheet; the text used
|
||||
to arrive flattened and had to be put back by hand every time. `target` is the
|
||||
x-ref of the textarea this toolbar acts on.
|
||||
#}
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button type="button"
|
||||
@click="wrapSelectionBold($refs.{{ target }})"
|
||||
title="{{ _('Grassetto: racchiude il testo selezionato fra **') }}"
|
||||
class="px-2 py-0.5 rounded border border-[var(--border-color)]
|
||||
text-sm font-bold leading-none
|
||||
text-[var(--text-secondary)] hover:text-primary hover:border-primary
|
||||
transition-colors">
|
||||
B
|
||||
</button>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">
|
||||
{{ _('**grassetto** · a capo = a capo') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block title %}{{ _('Stazione non configurata') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<div class="tmf-page tmf-page-narrow">
|
||||
<div class="mt-20 p-8 bg-[var(--bg-card)] rounded-xl shadow-lg text-center border border-red-200 dark:border-red-800">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
|
||||
bg-red-50 dark:bg-red-900/30 mb-6">
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
{% set current_version = recipe.current_version if recipe and recipe.current_version else None %}
|
||||
{% set versions = recipe.versions if recipe and recipe.versions else [] %}
|
||||
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl"
|
||||
<div class="tmf-page tmf-page-narrow"
|
||||
x-data="recipeEditor()"
|
||||
x-cloak>
|
||||
|
||||
@@ -248,6 +248,48 @@
|
||||
{{ _('Timer cicalino per ricordare la misurazione periodica') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Regole di misura: cosa la ricetta pretende dall'operatore -->
|
||||
<div class="sm:col-span-2 pt-2 border-t border-[var(--border-color)]">
|
||||
<p class="tmf-label mb-2">{{ _('Regole di misura') }}</p>
|
||||
<div class="grid sm:grid-cols-3 gap-3">
|
||||
|
||||
<label class="flex items-start gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="requiresLot"
|
||||
class="mt-0.5 rounded border-[var(--border-color)] text-primary focus:ring-primary">
|
||||
<span>
|
||||
<span class="text-sm text-[var(--text-primary)]">{{ _('Lotto obbligatorio') }}</span>
|
||||
<span class="block text-xs text-[var(--text-muted)]">
|
||||
{{ _('Senza lotto la produzione non parte') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="requiresSerial"
|
||||
class="mt-0.5 rounded border-[var(--border-color)] text-primary focus:ring-primary">
|
||||
<span>
|
||||
<span class="text-sm text-[var(--text-primary)]">{{ _('Seriale obbligatorio') }}</span>
|
||||
<span class="block text-xs text-[var(--text-muted)]">
|
||||
{{ _('Senza seriale la produzione non parte') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{# Off by default: the caliper is the instrument, and a value that
|
||||
happens to be in tolerance must not be enterable by hand. #}
|
||||
<label class="flex items-start gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="allowManualInput"
|
||||
class="mt-0.5 rounded border-[var(--border-color)] text-primary focus:ring-primary">
|
||||
<span>
|
||||
<span class="text-sm text-[var(--text-primary)]">{{ _('Consenti inserimento manuale') }}</span>
|
||||
<span class="block text-xs text-[var(--text-muted)]">
|
||||
{{ _('Se disattivo si misura solo col calibro') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,6 +528,13 @@ function recipeEditor() {
|
||||
// ---- Measurement interval ----
|
||||
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
|
||||
|
||||
// ---- Rules of measurement (points 8 and 9) ----
|
||||
// A new recipe forbids typing: the caliper is the instrument, and permission
|
||||
// to type is what has to be given, not taken away.
|
||||
requiresLot: {{ (recipe.requires_lot|default(false) if recipe else false)|tojson }},
|
||||
requiresSerial: {{ (recipe.requires_serial|default(false) if recipe else false)|tojson }},
|
||||
allowManualInput: {{ (recipe.allow_manual_input|default(false) if recipe else false)|tojson }},
|
||||
|
||||
// ---- File upload (preview image) ----
|
||||
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
|
||||
uploadingFile: false,
|
||||
@@ -512,6 +561,12 @@ function recipeEditor() {
|
||||
payload.measurement_interval_minutes = parseInt(this.measurementIntervalMinutes, 10);
|
||||
}
|
||||
|
||||
// Rules of measurement. Sent every time, including when false: these are the
|
||||
// settings an operator gets told about, so turning one off has to travel.
|
||||
payload.requires_lot = !!this.requiresLot;
|
||||
payload.requires_serial = !!this.requiresSerial;
|
||||
payload.allow_manual_input = !!this.allowManualInput;
|
||||
|
||||
// Include image_path for preview thumbnail
|
||||
if (this.currentFilePath) {
|
||||
payload.image_path = this.currentFilePath;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
{% block content %}
|
||||
<script>window.__recipeListData = {{ recipes|tojson }};</script>
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-6xl"
|
||||
<div class="tmf-page"
|
||||
x-data="{
|
||||
recipes: window.__recipeListData,
|
||||
search: '{{ search or '' }}',
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</script>
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl"
|
||||
<div class="tmf-page"
|
||||
x-data="recipePreview()"
|
||||
x-cloak>
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
<h2 class="font-semibold text-[var(--text-primary)]">{{ task.title }}</h2>
|
||||
{% if task.directive or task.description %}
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
|
||||
{{ task.directive or task.description }}
|
||||
{{ (task.directive or task.description)|rich_text }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
connectionError: {{ _("Errore di connessione al server")|tojson }}
|
||||
};
|
||||
</script>
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl"
|
||||
<div class="tmf-page"
|
||||
x-data="taskDrawing()"
|
||||
x-cloak>
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl"
|
||||
<div class="tmf-page"
|
||||
x-data="taskEditor()"
|
||||
x-cloak>
|
||||
|
||||
@@ -292,8 +292,14 @@
|
||||
</div>
|
||||
<!-- Descrizione -->
|
||||
<div class="sm:col-span-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||
{% with target = 'newTaskDescription' %}
|
||||
{% include "components/rich_text_toolbar.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<textarea x-model="newTask.description"
|
||||
x-ref="newTaskDescription"
|
||||
class="tmf-input text-sm"
|
||||
rows="3"
|
||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||
@@ -519,7 +525,10 @@
|
||||
<template x-if="task.description">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide shrink-0 mt-0.5">{{ _('Descrizione') }}:</span>
|
||||
<span class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap" x-text="task.description"></span>
|
||||
{# Shown the way the operator will see it - the point of the
|
||||
formatting is that the maker can check it here. #}
|
||||
<span class="text-sm text-[var(--text-secondary)]"
|
||||
x-html="richText(task.description)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -544,8 +553,14 @@
|
||||
placeholder="{{ _('Direttiva opzionale...') }}">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||
{% with target = 'editTaskDescription' %}
|
||||
{% include "components/rich_text_toolbar.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<textarea x-model="editTaskData.description"
|
||||
x-ref="editTaskDescription"
|
||||
class="tmf-input text-sm"
|
||||
rows="3"
|
||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||
@@ -1076,6 +1091,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
||||
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
||||
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl"
|
||||
<div class="tmf-page tmf-page-narrow"
|
||||
x-data="versionHistory()"
|
||||
x-init="loadMeasurementCounts()"
|
||||
x-cloak>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
{% block content %}
|
||||
<script>window.__selectRecipeData = {{ recipes|tojson }};</script>
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-7xl"
|
||||
<div class="tmf-page"
|
||||
x-data="{
|
||||
recipes: window.__selectRecipeData,
|
||||
search: '{{ auto_recipe_code }}',
|
||||
@@ -22,8 +22,20 @@
|
||||
(r.description || '').toLowerCase().includes(q)
|
||||
);
|
||||
},
|
||||
/* Which compulsory traceability fields this recipe is still missing.
|
||||
Empty means the operator can start. The server refuses the same thing
|
||||
when the production opens - this is only the earlier answer. */
|
||||
missingTraceability(recipe) {
|
||||
const missing = [];
|
||||
if (recipe.requires_lot && !this.lot_number.trim()) missing.push('{{ _('lotto') }}');
|
||||
if (recipe.requires_serial && !this.serial_number.trim()) missing.push('{{ _('seriale') }}');
|
||||
return missing;
|
||||
},
|
||||
/* Straight into the first task. Choosing a recipe used to open the full
|
||||
task list, which asked the operator to decide where to begin before they
|
||||
had done anything; the list is still one tap away from inside the task. */
|
||||
buildTaskUrl(recipeId) {
|
||||
let url = '/measure/tasks/' + recipeId + '?';
|
||||
let url = '/measure/start/' + recipeId + '?';
|
||||
const params = [];
|
||||
if (this.lot_number) params.push('lot_number=' + encodeURIComponent(this.lot_number));
|
||||
if (this.serial_number) params.push('serial_number=' + encodeURIComponent(this.serial_number));
|
||||
@@ -47,6 +59,14 @@
|
||||
if (data.error) {
|
||||
this.barcodeError = data.detail || '{{ _("Ricetta non trovata") }}';
|
||||
} else {
|
||||
// The scanner is a way in like any other: a recipe that demands a lot
|
||||
// demands it here too, or the rule would last exactly as long as it
|
||||
// took someone to reach for the barcode reader.
|
||||
const missing = this.missingTraceability(data);
|
||||
if (missing.length) {
|
||||
this.barcodeError = '{{ _("Compila prima:") }} ' + missing.join(', ');
|
||||
return;
|
||||
}
|
||||
this.barcodeModal = false;
|
||||
this.barcodeInput = '';
|
||||
window.location.href = this.buildTaskUrl(data.id);
|
||||
@@ -152,7 +172,7 @@
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="lot_number"
|
||||
placeholder="{{ _('Es. LOT-2026-001 (opzionale)') }}"
|
||||
placeholder="{{ _('Es. LOT-2026-001') }}"
|
||||
class="tmf-input font-mono">
|
||||
</div>
|
||||
|
||||
@@ -166,7 +186,7 @@
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="serial_number"
|
||||
placeholder="{{ _('Es. SN-000123 (opzionale)') }}"
|
||||
placeholder="{{ _('Es. SN-000123') }}"
|
||||
class="tmf-input font-mono">
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,16 +268,38 @@
|
||||
<!-- Card Footer -->
|
||||
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
|
||||
rounded-b-xl">
|
||||
<a :href="buildTaskUrl(recipe.id)"
|
||||
|
||||
{# What this recipe insists on before anyone measures. Said on the card
|
||||
rather than after the fact: a measurement taken without its lot cannot
|
||||
be traced back, and finding that out later is finding it out too late. #}
|
||||
<template x-if="missingTraceability(recipe).length">
|
||||
<p class="mb-2 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
<svg class="w-3.5 h-3.5 shrink-0 mt-px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
||||
</svg>
|
||||
<span x-text="'{{ _('Compila prima:') }} ' + missingTraceability(recipe).join(', ')"></span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<a :href="missingTraceability(recipe).length ? null : buildTaskUrl(recipe.id)"
|
||||
:aria-disabled="missingTraceability(recipe).length > 0"
|
||||
:class="missingTraceability(recipe).length
|
||||
? 'opacity-40 cursor-not-allowed pointer-events-none'
|
||||
: 'group-hover:shadow-md'"
|
||||
class="btn btn-primary w-full justify-center text-sm font-semibold
|
||||
group-hover:shadow-md transition-shadow duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
{{ _('Seleziona') }}
|
||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
transition-shadow duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||
</svg>
|
||||
{{ _('AVVIA IN SEQUENZA') }}
|
||||
</a>
|
||||
|
||||
{# The whole list, for when the operator wants to see it or go back to a
|
||||
task left unfinished. Deliberately the quiet one of the two. #}
|
||||
<a :href="'/measure/tasks/' + recipe.id"
|
||||
class="mt-2 block text-center text-xs text-[var(--text-secondary)]
|
||||
hover:text-primary transition-colors">
|
||||
{{ _('Vedi tutti i task') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}{{ _('Riepilogo') }} - {{ recipe.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="tmf-page">
|
||||
{# The summary is where an operator lingers: the clock has to be here as well. #}
|
||||
{% include "components/production_clock.html" %}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
x-data="taskExecute()"
|
||||
x-init="init()"
|
||||
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
||||
@numpad-rejected.window="onNumpadRejected($event.detail)"
|
||||
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
|
||||
|
||||
{# ================================================================
|
||||
@@ -95,7 +96,16 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{# Lista task + Riepilogo buttons #}
|
||||
{#
|
||||
Lista task + Completato.
|
||||
|
||||
The list is the second level now: the operator arrives inside the sequence
|
||||
and comes here when they want the whole of it. What used to sit beside it
|
||||
was «Riepilogo», which jumped out of the sequence to a recap - the wrong
|
||||
thing to offer someone halfway through a task. The way forward is
|
||||
«Completato», and on a measurement task it stays shut until the cycle is
|
||||
closed, which is the same rule the footer enforces.
|
||||
#}
|
||||
<div class="shrink-0 flex items-center gap-1.5">
|
||||
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
||||
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
|
||||
@@ -104,12 +114,15 @@
|
||||
</svg>
|
||||
{{ _('Lista task') }}
|
||||
</a>
|
||||
<button @click="goToSummary()"
|
||||
<button @click="goToNextTask()"
|
||||
:disabled="!canLeaveTask"
|
||||
:title="canLeaveTask ? '' : blockedReason"
|
||||
:class="canLeaveTask ? '' : 'opacity-40 cursor-not-allowed'"
|
||||
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ _('Riepilogo') }}
|
||||
{{ _('Completato') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -229,6 +242,26 @@
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="flex-1 flex flex-col overflow-hidden bg-[var(--bg-secondary)]">
|
||||
|
||||
{# What the task says to do. It was written for the operator and until now
|
||||
only the maker ever saw it; the line breaks and the bold of the technical
|
||||
sheet survive here as they do in the editor. #}
|
||||
{% if task.directive or task.description %}
|
||||
<div class="shrink-0 max-h-36 overflow-y-auto px-4 py-2.5
|
||||
bg-[var(--bg-card)] border-b border-[var(--border-color)]">
|
||||
{% if task.directive %}
|
||||
<p class="text-sm font-semibold text-[var(--text-primary)] leading-snug">
|
||||
{{ task.directive|rich_text }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if task.description %}
|
||||
<div class="text-sm text-[var(--text-secondary)] leading-relaxed
|
||||
{% if task.directive %}mt-1{% endif %}">
|
||||
{{ task.description|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Main image area #}
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
|
||||
@@ -472,6 +505,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
QUOTA FUORI TOLLERANZA — nothing moves from here without the capoturno or a
|
||||
new reading of this same quote. Kept on screen after the modal is dismissed,
|
||||
because the block outlives the modal: the server refuses the next value.
|
||||
================================================================ #}
|
||||
<div x-show="blockedByTolerance"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="shrink-0 bg-red-50 dark:bg-red-900/30 border-t-2 border-red-500 px-4 py-2">
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
||||
<svg class="w-5 h-5 text-red-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-red-900 dark:text-red-100">
|
||||
{{ _('Quota fuori tolleranza') }}
|
||||
</span>
|
||||
<span class="text-xs text-red-800 dark:text-red-200">
|
||||
{{ _('serve il capoturno, oppure una nuova misura della stessa quota') }}
|
||||
</span>
|
||||
<button @click="openSupervisorModal('out_of_tolerance')"
|
||||
class="btn text-xs py-1 px-2.5 bg-red-600 hover:bg-red-700 text-white">
|
||||
{{ _('Autorizza') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
LINEA FERMA — the countdown is frozen, not merely hidden. Stated plainly
|
||||
because a stopped line that looks like a running one is how a measurement
|
||||
@@ -583,18 +642,30 @@
|
||||
x-text="Math.round(progressPercent) + '%'"></span>
|
||||
</div>
|
||||
|
||||
{# Right: Fine ciclo misura (measurement tasks) / Completato (non-measurement or after cycle) #}
|
||||
{# Right: Fine ciclo misura (measurement tasks) / Task successivo (after cycle) #}
|
||||
<template x-if="isMeasureTask && subtasks.length > 0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button x-show="isComplete && !cycleConfirmed"
|
||||
{#
|
||||
Shown from the start, and greyed until every quote has a value. It used
|
||||
to appear only once the task was finished, which left the operator with
|
||||
no sign that the button existed or what would bring it out - a control
|
||||
that is missing teaches nothing, one that is disabled says what is left.
|
||||
#}
|
||||
<button x-show="!cycleConfirmed"
|
||||
x-transition
|
||||
@click="confirmCycle()"
|
||||
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
||||
bg-primary text-white hover:bg-primary-700">
|
||||
:disabled="!isComplete"
|
||||
:title="isComplete ? '' : missingQuotesLabel"
|
||||
:class="isComplete
|
||||
? 'bg-primary text-white hover:bg-primary-700 shadow-md'
|
||||
: 'bg-steel-200 dark:bg-steel-700 text-steel-500 dark:text-steel-400 cursor-not-allowed'"
|
||||
class="btn text-xs shrink-0 gap-1 py-1.5 px-3">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Fine ciclo misura') }}
|
||||
<span x-text="isComplete
|
||||
? {{ _('Fine ciclo misura')|tojson_attr }}
|
||||
: missingQuotesLabel"></span>
|
||||
</button>
|
||||
{# Girare il pezzo e rimisurare, senza chiudere il ciclo: a second reading
|
||||
of the same part is not a new one, and must not restart the interval. #}
|
||||
@@ -607,6 +678,9 @@
|
||||
</svg>
|
||||
{{ _('Rimisura') }}
|
||||
</button>
|
||||
{# The cycle is closed; this only walks on. «Completato» is the header's
|
||||
word for finishing a task, and two buttons wearing it would be one too
|
||||
many. #}
|
||||
<button x-show="cycleConfirmed"
|
||||
x-transition
|
||||
@click="goToNextTask()"
|
||||
@@ -615,7 +689,7 @@
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ _('Completato') }}
|
||||
{{ _('Task successivo') }}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||
</svg>
|
||||
@@ -805,9 +879,13 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{# Out of tolerance, the way out of this modal is measuring the quote
|
||||
again - not cancelling. Closing it changes nothing: the quote stays
|
||||
blocked, and the banner behind says so. #}
|
||||
<button @click="closeSupervisorModal()"
|
||||
class="btn btn-secondary flex-1 text-sm">
|
||||
{{ _('Annulla') }}
|
||||
<span x-show="supervisorAction !== 'out_of_tolerance'">{{ _('Annulla') }}</span>
|
||||
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
|
||||
</button>
|
||||
<button @click="submitSupervisor()"
|
||||
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
|
||||
@@ -888,7 +966,10 @@ function taskExecute() {
|
||||
supervisorPassword: '',
|
||||
supervisorError: '',
|
||||
supervisorValidating: false,
|
||||
pendingAdvance: false,
|
||||
/* The out-of-tolerance measurement waiting for the capoturno:
|
||||
{ id, subtask_id }, or null. While it is set the operator stays on that
|
||||
quote - the server refuses the next one anyway. */
|
||||
pendingFail: null,
|
||||
|
||||
// ---- Value from numpad / caliper ----
|
||||
currentValue: null,
|
||||
@@ -934,6 +1015,40 @@ function taskExecute() {
|
||||
: 0;
|
||||
},
|
||||
|
||||
/* How many quotes are still owed, worded rather than counted down in silence.
|
||||
This is what the greyed «Fine ciclo misura» says instead of nothing. */
|
||||
get missingQuotes() {
|
||||
return Math.max(0, this.totalSubtasks - this.completedCount);
|
||||
},
|
||||
|
||||
get missingQuotesLabel() {
|
||||
const n = this.missingQuotes;
|
||||
return n === 1
|
||||
? {{ _('Manca 1 quota')|tojson }}
|
||||
: {{ _('Mancano')|tojson }} + ' ' + n + ' ' + {{ _('quote')|tojson }};
|
||||
},
|
||||
|
||||
/* Whether «Completato» can take the operator on.
|
||||
|
||||
A documental task is done when they say it is. A measurement task is done
|
||||
when its cycle is closed - otherwise the button would be a way round «Fine
|
||||
ciclo misura», and the quotes it exists to collect would be optional. */
|
||||
get canLeaveTask() {
|
||||
if (this.blockedByTolerance) return false;
|
||||
if (this.isMeasureTask && this.subtasks.length > 0) {
|
||||
return this.cycleConfirmed;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
get blockedReason() {
|
||||
if (this.blockedByTolerance) {
|
||||
return {{ _("Serve l'autorizzazione del capoturno")|tojson }};
|
||||
}
|
||||
if (!this.isComplete) return this.missingQuotesLabel;
|
||||
return {{ _('Chiudi prima il ciclo di misura')|tojson }};
|
||||
},
|
||||
|
||||
// ---- Pass/fail logic ----
|
||||
get passFailStatus() {
|
||||
if (this.currentValue === null || !this.currentSubtask) return null;
|
||||
@@ -973,11 +1088,40 @@ function taskExecute() {
|
||||
},
|
||||
|
||||
// ---- Init ----
|
||||
init() {
|
||||
async init() {
|
||||
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
||||
this.inputStartedAt = Date.now();
|
||||
// Rejoin whatever production is already open at this station.
|
||||
this.loadProductionRun();
|
||||
await this.loadProductionRun();
|
||||
await this.loadPendingAuthorisation();
|
||||
},
|
||||
|
||||
/* Is a quote already waiting for the capoturno?
|
||||
|
||||
Reloading the page was the simplest way past the old gate. It is not one
|
||||
now - the server refuses the next measurement either way - so the screen
|
||||
picks the block back up as soon as it opens, on the quote it belongs to. */
|
||||
async loadPendingAuthorisation() {
|
||||
if (!this.isMeasureTask || !this.subtasks.length) return;
|
||||
try {
|
||||
const params = new URLSearchParams({ version_id: this.task.version_id });
|
||||
if (this.productionRun) {
|
||||
params.set('production_run_id', this.productionRun.id);
|
||||
}
|
||||
const resp = await fetch(
|
||||
'{{ url_for("measure.api_pending_authorisation") }}?' + params.toString(),
|
||||
);
|
||||
if (!resp.ok) return;
|
||||
const pending = await resp.json();
|
||||
if (!pending || !pending.id) return;
|
||||
|
||||
this.pendingFail = { id: pending.id, subtask_id: pending.subtask_id };
|
||||
const idx = this.subtasks.findIndex(s => s.id === pending.subtask_id);
|
||||
if (idx !== -1) this.currentIndex = idx;
|
||||
this.openSupervisorModal('out_of_tolerance');
|
||||
} catch (e) {
|
||||
// Offline: the banner is missing, but so is the ability to save anything.
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Production run: read the state back from the server ----
|
||||
@@ -1034,6 +1178,15 @@ function taskExecute() {
|
||||
return m ? m.pass_fail : null;
|
||||
},
|
||||
|
||||
/* The keypad refused a value because this recipe wants the caliper. Said on
|
||||
the same line as any other measurement error, so there is one place to look
|
||||
when a value does not go in. */
|
||||
onNumpadRejected(detail) {
|
||||
if (detail && detail.reason === 'manual_not_allowed') {
|
||||
this.errorMessage = '{{ _("Questa ricetta non ammette valori digitati: usare il calibro") }}';
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Handle numpad confirm ----
|
||||
async handleMeasurement(value, inputMethod) {
|
||||
if (!this.currentSubtask || this.saving) return;
|
||||
@@ -1094,13 +1247,21 @@ function taskExecute() {
|
||||
// Pause to show result feedback
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Out-of-tolerance: block advancement, require supervisor
|
||||
// Out of tolerance: nothing moves until the capoturno says so, or until
|
||||
// this same quote is measured again and comes out inside tolerance.
|
||||
if (pf === 'fail') {
|
||||
this.pendingAdvance = true;
|
||||
this.pendingFail = { id: result.id, subtask_id: this.currentSubtask.id };
|
||||
this.openSupervisorModal('out_of_tolerance');
|
||||
return;
|
||||
}
|
||||
|
||||
// A second reading of the blocked quote, this time in tolerance: the quote
|
||||
// is in tolerance now, so the block goes. Both readings stay on record -
|
||||
// how many attempts are allowed is point 4's business, not this one's.
|
||||
if (this.pendingFail && this.pendingFail.subtask_id === this.currentSubtask.id) {
|
||||
this.pendingFail = null;
|
||||
}
|
||||
|
||||
// Check if all done
|
||||
if (this.completedCount >= this.totalSubtasks) {
|
||||
// START run (production not yet started): don't block with the
|
||||
@@ -1143,14 +1304,38 @@ function taskExecute() {
|
||||
}
|
||||
},
|
||||
|
||||
/* Out of tolerance and nobody has authorised it: the operator stays put.
|
||||
|
||||
This is the whole of point 5. The screen used to open a modal that could be
|
||||
dismissed, and the measurement was already saved by then, so a value outside
|
||||
tolerance could be left behind by clicking the backdrop. The server refuses
|
||||
the next measurement now; these checks only spare the operator the trip. */
|
||||
get blockedByTolerance() {
|
||||
return this.pendingFail !== null;
|
||||
},
|
||||
|
||||
/* Say why nothing is moving, and offer the way out. */
|
||||
refuseWhileBlocked() {
|
||||
// |tojson, not quotes: the Italian carries an apostrophe, and an apostrophe
|
||||
// inside a single-quoted literal is how every Alpine binding on the page dies.
|
||||
this.errorMessage = {{ _("Quota fuori tolleranza: serve l'autorizzazione del capoturno, oppure misurare di nuovo la quota")|tojson }};
|
||||
this.openSupervisorModal('out_of_tolerance');
|
||||
},
|
||||
|
||||
// ---- Navigation ----
|
||||
goToSubtask(index) {
|
||||
if (index >= 0 && index < this.totalSubtasks) {
|
||||
if (index < 0 || index >= this.totalSubtasks) return;
|
||||
// Moving to another quote is exactly what a blocked quote forbids; going
|
||||
// back to the blocked one to measure it again is not.
|
||||
if (this.blockedByTolerance
|
||||
&& this.subtasks[index].id !== this.pendingFail.subtask_id) {
|
||||
this.refuseWhileBlocked();
|
||||
return;
|
||||
}
|
||||
this.currentIndex = index;
|
||||
this.currentValue = null;
|
||||
this.errorMessage = '';
|
||||
this.inputStartedAt = Date.now();
|
||||
}
|
||||
},
|
||||
|
||||
goToSubtaskByMarker(markerNumber) {
|
||||
@@ -1165,6 +1350,12 @@ function taskExecute() {
|
||||
it if it is the last. The server decides that - it knows the sequence - and
|
||||
the cycle count coming back tells us which of the two happened. */
|
||||
async confirmCycle() {
|
||||
// Closing the cycle would be another way of walking past a quote that is out
|
||||
// of tolerance. The server refuses it too.
|
||||
if (this.blockedByTolerance) {
|
||||
this.refuseWhileBlocked();
|
||||
return;
|
||||
}
|
||||
this.cycleConfirmed = true;
|
||||
this.showCompletionOverlay = false;
|
||||
|
||||
@@ -1411,6 +1602,10 @@ function taskExecute() {
|
||||
documental tasks that come before the measurement, the plain sequence
|
||||
applies - that is the run-up, not the loop. */
|
||||
goToNextTask() {
|
||||
if (this.blockedByTolerance) {
|
||||
this.refuseWhileBlocked();
|
||||
return;
|
||||
}
|
||||
if (this.productionStarted && this.isMeasurementTaskOfRun) {
|
||||
const next = this.nextMeasurementTaskId || this.measurementTaskIds[0];
|
||||
// A recipe with a single measurement task loops on the spot: reloading the
|
||||
@@ -1481,48 +1676,58 @@ function taskExecute() {
|
||||
|
||||
/* Route the modal's confirm to what the action actually does. Fermo linea,
|
||||
ripresa and fine produzione act on the production run; the out-of-tolerance
|
||||
gate stays a pure credential check. */
|
||||
one is recorded on the measurement itself. */
|
||||
async submitSupervisor() {
|
||||
if (this.supervisorAction === 'fermo_linea') return this.runSupervisedAction('pause');
|
||||
if (this.supervisorAction === 'ripresa') return this.runSupervisedAction('resume');
|
||||
if (this.supervisorAction === 'fine_produzione') return this.runSupervisedAction('close');
|
||||
return this.validateSupervisor();
|
||||
return this.authoriseOutOfTolerance();
|
||||
},
|
||||
|
||||
// ---- Validate supervisor credentials ----
|
||||
async validateSupervisor() {
|
||||
/* The capoturno lets an out-of-tolerance value stand.
|
||||
|
||||
The credentials go to the endpoint that writes the approval onto that
|
||||
measurement. It used to be a bare credential check whose answer was thrown
|
||||
away: nothing recorded who had said yes, and nothing depended on it, so
|
||||
closing the modal was enough to walk past a value out of tolerance. */
|
||||
async authoriseOutOfTolerance() {
|
||||
if (!this.pendingFail) {
|
||||
this.closeSupervisorModal();
|
||||
return;
|
||||
}
|
||||
this.supervisorError = '';
|
||||
this.supervisorValidating = true;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const resp = await fetch('{{ url_for("measure.validate_supervisor") }}', {
|
||||
const url = '{{ url_for("measure.api_authorise_measurement", measurement_id=0) }}'
|
||||
.replace('/0/', '/' + this.pendingFail.id + '/');
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify({ username: this.supervisorUsername, password: this.supervisorPassword })
|
||||
body: JSON.stringify({
|
||||
supervisor_username: this.supervisorUsername,
|
||||
supervisor_password: this.supervisorPassword,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
const data = await resp.json().catch(() => null);
|
||||
|
||||
if (!resp.ok || data.error) {
|
||||
this.supervisorError = data.detail || '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
||||
if (!resp.ok || (data && data.error)) {
|
||||
this.supervisorError = (data && data.detail)
|
||||
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
||||
this.supervisorValidating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Authorized — close modal and proceed
|
||||
this.showSupervisorModal = false;
|
||||
this.supervisorUsername = '';
|
||||
this.supervisorPassword = '';
|
||||
this.supervisorValidating = false;
|
||||
this.closeSupervisorModal();
|
||||
this.pendingFail = null;
|
||||
|
||||
if (this.supervisorAction === 'out_of_tolerance') {
|
||||
this.pendingAdvance = false;
|
||||
if (this.completedCount >= this.totalSubtasks) {
|
||||
this.showCompletionOverlay = true;
|
||||
} else {
|
||||
this.advanceToNext();
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
this.supervisorError = '{{ _("Errore di connessione") }}';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block title %}{{ recipe.name }} — {{ _('Task') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl">
|
||||
<div class="tmf-page">
|
||||
|
||||
{# The clock follows the operator here too, and brings them back when it expires. #}
|
||||
{% include "components/production_clock.html" %}
|
||||
@@ -75,18 +75,39 @@
|
||||
</div>
|
||||
|
||||
<!-- Right: AVVIA + Traceability -->
|
||||
{#
|
||||
Avvia does not start while the recipe is still owed its traceability.
|
||||
The selection screen asks for it first, but this page can be reached
|
||||
directly - by a bookmark, or by going back - and the rule has to hold
|
||||
on whichever door the operator comes through.
|
||||
#}
|
||||
{% set needs_lot = recipe.requires_lot|default(false) and not lot_number %}
|
||||
{% set needs_serial = recipe.requires_serial|default(false) and not serial_number %}
|
||||
{% set missing_trace = ([_('lotto')] if needs_lot else [])
|
||||
+ ([_('seriale')] if needs_serial else []) %}
|
||||
<div class="flex flex-col gap-3 sm:items-end shrink-0">
|
||||
{% if tasks %}
|
||||
<a href="{{ url_for('measure.task_execute', task_id=tasks[0].id) }}"
|
||||
{% if missing_trace %}
|
||||
<a href="{{ url_for('measure.select_recipe', recipe=recipe.code) }}"
|
||||
class="btn gap-3 w-full sm:w-auto justify-center text-lg font-bold
|
||||
bg-steel-400 text-white px-8 py-3 rounded-xl cursor-pointer">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
||||
</svg>
|
||||
{{ _('Compila prima:') }} {{ missing_trace|join(', ') }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('measure.start_sequence', recipe_id=recipe.id) }}"
|
||||
class="btn gap-3 w-full sm:w-auto justify-center text-lg font-bold
|
||||
bg-red-600 hover:bg-red-700 text-white shadow-lg hover:shadow-xl
|
||||
px-8 py-3 rounded-xl transition-all duration-200">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||
</svg>
|
||||
{{ _('AVVIA') }}
|
||||
{{ _('AVVIA IN SEQUENZA') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if lot_number %}
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||
@@ -141,7 +162,12 @@
|
||||
{% if tasks %}
|
||||
<div class="space-y-2.5">
|
||||
{% for task in tasks %}
|
||||
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
|
||||
{# How far this task got. A task walked away from halfway through used to look
|
||||
exactly like one never opened; this is what tells them apart. #}
|
||||
{% set prog = (progress|default({})).get(task.id) %}
|
||||
<div class="tmf-card transition-all duration-200 group
|
||||
{% if prog and prog.state == 'partial' %}border-amber-300 dark:border-amber-700
|
||||
{% else %}hover:border-primary/30{% endif %}">
|
||||
<div class="p-3 sm:p-4">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
|
||||
@@ -162,7 +188,7 @@
|
||||
<!-- Directive -->
|
||||
{% if task.directive or task.description %}
|
||||
<p class="text-xs text-[var(--text-secondary)] leading-snug mt-0.5 line-clamp-2">
|
||||
{{ task.directive or task.description }}
|
||||
{{ (task.directive or task.description)|rich_text }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -175,7 +201,7 @@
|
||||
'measure': _('Misura'),
|
||||
'drawing': _('Disegno'),
|
||||
'note': _('Nota'),
|
||||
'xf_compare': _('Confronto profilo'),
|
||||
'dxf_compare': _('Confronto profilo'),
|
||||
'camera_measure': _('Misura camera'),
|
||||
} %}
|
||||
{% set is_measure = task.task_type in ('measure', 'camera_measure') %}
|
||||
@@ -187,7 +213,27 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if task.subtask_count is defined or task.subtasks %}
|
||||
{% if prog and prog.state in ('partial', 'complete') %}
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-semibold
|
||||
{% if prog.state == 'complete' %}
|
||||
bg-measure-pass/10 text-measure-pass
|
||||
{% else %}
|
||||
bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300
|
||||
{% endif %}">
|
||||
{% if prog.state == 'complete' %}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ _('Completato') }}
|
||||
{% else %}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l2.5 2.5M12 21a9 9 0 110-18 9 9 0 010 18z"/>
|
||||
</svg>
|
||||
{{ _('Incompiuto') }}
|
||||
{% endif %}
|
||||
<span class="font-mono">{{ prog.measured }}/{{ prog.quotes }}</span>
|
||||
</span>
|
||||
{% elif task.subtask_count is defined or task.subtasks %}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-[var(--text-secondary)]"
|
||||
title="{{ _('misurazioni') }}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
@@ -212,23 +258,23 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Action Button (Maker only — operators start the guided flow with AVVIA) -->
|
||||
{% if current_user and 'Maker' in current_user.get('roles', []) %}
|
||||
{#
|
||||
Opening one task on its own. The sequence is the normal way through the
|
||||
recipe - this is the way back to a task left unfinished, which is why the
|
||||
operator has it now and not only the Maker: a list that says "incompiuto"
|
||||
and gives you no way there would be a complaint, not a feature.
|
||||
#}
|
||||
<div class="shrink-0 sm:ml-4">
|
||||
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
||||
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
|
||||
class="btn btn-secondary gap-2 w-full sm:w-auto justify-center
|
||||
group-hover:shadow-md transition-shadow duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Visualizza Task') }}
|
||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
{{ _('Visualizza singolo TASK') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-6"
|
||||
<div class="tmf-page"
|
||||
x-data="spcDashboard()"
|
||||
x-init="init()">
|
||||
|
||||
|
||||
@@ -73,6 +73,27 @@ def logged_in_client(flask_app):
|
||||
yield test_client
|
||||
|
||||
|
||||
def api_get_router(routes: dict, default=None):
|
||||
"""A ``get`` side effect that answers by endpoint instead of by turn.
|
||||
|
||||
A list of responses in call order breaks the moment a page asks the server one
|
||||
more question - which is how every page grows. Matching on the endpoint says
|
||||
what a test actually means: this is what /api/recipes/3 replies.
|
||||
|
||||
Keys are matched as substrings of the endpoint, longest first, so
|
||||
``/api/recipes/3/tasks`` wins over ``/api/recipes/3``.
|
||||
"""
|
||||
ordered = sorted(routes.items(), key=lambda kv: -len(kv[0]))
|
||||
|
||||
def _get(endpoint, *args, **kwargs):
|
||||
for prefix, response in ordered:
|
||||
if prefix in endpoint:
|
||||
return response() if callable(response) else response
|
||||
return {} if default is None else default
|
||||
|
||||
return _get
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_client():
|
||||
"""Patch the api_client singleton used in blueprints.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Point 14: the frame must not move when the operator changes view.
|
||||
|
||||
The report was «le dimensioni delle viste cambiano a seconda del menu». On the
|
||||
code it was seven different page widths across seventeen templates, none of them
|
||||
the width of the navbar sitting above them, plus a scrollbar that came and went.
|
||||
|
||||
This test guards the part that is easy to undo: the next page someone writes will
|
||||
copy the shell from a neighbour, and if a neighbour has gone back to declaring its
|
||||
own `container mx-auto ... max-w-5xl` the drift starts again.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
TEMPLATES = Path(__file__).resolve().parents[1] / "templates"
|
||||
|
||||
# Two views own their geometry on purpose, and say why here so that a reader does
|
||||
# not have to guess whether they were forgotten:
|
||||
# login — a full-viewport splash, no navbar to line up with;
|
||||
# task_execute — the measurement screen, a fixed full-height instrument panel
|
||||
# that must not scroll while an operator is measuring.
|
||||
EXEMPT = {"auth/login.html", "measure/task_execute.html"}
|
||||
|
||||
# What a page must not go back to doing: setting its own width and padding.
|
||||
FORBIDDEN = ("container mx-auto", "max-w-2xl mx-auto", "max-w-3xl mx-auto",
|
||||
"max-w-4xl mx-auto", "max-w-5xl mx-auto", "max-w-6xl mx-auto",
|
||||
"max-w-7xl mx-auto")
|
||||
|
||||
|
||||
def _views():
|
||||
for path in sorted(TEMPLATES.rglob("*.html")):
|
||||
rel = path.relative_to(TEMPLATES).as_posix()
|
||||
if rel.startswith("components/") or rel == "base.html":
|
||||
continue
|
||||
yield rel, path
|
||||
|
||||
|
||||
def test_every_view_uses_the_shared_shell():
|
||||
"""One frame, declared in one place, so it cannot drift per page."""
|
||||
missing = [
|
||||
rel for rel, path in _views()
|
||||
if rel not in EXEMPT and "tmf-page" not in path.read_text(encoding="utf-8")
|
||||
]
|
||||
assert not missing, f"viste senza la cornice condivisa: {missing}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rel,path", list(_views()))
|
||||
def test_no_view_declares_its_own_width(rel, path):
|
||||
if rel in EXEMPT:
|
||||
pytest.skip("geometria propria, documentata in EXEMPT")
|
||||
html = path.read_text(encoding="utf-8")
|
||||
found = [f for f in FORBIDDEN if f in html]
|
||||
assert not found, (
|
||||
f"{rel} torna a dichiarare la propria larghezza ({found}): "
|
||||
"usare .tmf-page / .tmf-page-narrow"
|
||||
)
|
||||
|
||||
|
||||
def test_the_shell_matches_the_navbar():
|
||||
"""The content lines up with the bar above it, or the page looks like it has
|
||||
two widths at once - which is what the operator was seeing."""
|
||||
navbar = (TEMPLATES / "components/navbar.html").read_text(encoding="utf-8")
|
||||
assert "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8" in navbar, (
|
||||
"la navbar ha cambiato cornice: allineare .tmf-page in themes.css"
|
||||
)
|
||||
css = (
|
||||
TEMPLATES.parent / "static/css/themes.css"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "max-width: 80rem;" in css # 80rem == max-w-7xl
|
||||
assert "scrollbar-gutter: stable;" in css
|
||||
@@ -4,6 +4,8 @@ Covers recipe selection, task list, login requirement, and measurement submissio
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from tests.conftest import api_get_router
|
||||
|
||||
|
||||
class TestSelectRecipe:
|
||||
"""GET /measure/select tests."""
|
||||
@@ -36,22 +38,129 @@ class TestTaskList:
|
||||
|
||||
def test_task_list_renders(self, logged_in_client, mock_api_client):
|
||||
"""Task list page renders with recipe and task data."""
|
||||
# First call: recipe details (dict), second call: tasks list.
|
||||
# The route calls tasks_resp.get("error") so the mock must return
|
||||
# a dict (not a bare list) to avoid AttributeError.
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||
{
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/recipes/1/tasks": {
|
||||
"items": [
|
||||
{"id": 1, "title": "Task 1", "order_index": 0},
|
||||
{"id": 2, "title": "Task 2", "order_index": 1},
|
||||
],
|
||||
},
|
||||
]
|
||||
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||
"/api/production-runs/current": None,
|
||||
})
|
||||
|
||||
resp = logged_in_client.get("/measure/tasks/1")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_task_list_marks_the_task_left_half_done(
|
||||
self, logged_in_client, mock_api_client,
|
||||
):
|
||||
"""A task with some of its quotes taken says so, and says how many."""
|
||||
with logged_in_client.session_transaction() as sess:
|
||||
sess["language"] = "it"
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/recipes/1/tasks": [
|
||||
{"id": 7, "title": "Misura", "order_index": 0, "task_type": "measure"},
|
||||
{"id": 8, "title": "Nota", "order_index": 1, "task_type": "note"},
|
||||
],
|
||||
"/api/recipes/1": {
|
||||
"id": 1, "code": "REC-001", "name": "Test Recipe",
|
||||
"current_version": {"id": 42, "version_number": 1},
|
||||
},
|
||||
"/api/production-runs/current": None,
|
||||
"/api/measurements/task-progress": {
|
||||
"tasks": [
|
||||
{"task_id": 7, "quotes": 3, "measured": 1, "state": "partial"},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
resp = logged_in_client.get("/measure/tasks/1")
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
assert "Incompiuto" in html
|
||||
assert "1/3" in html
|
||||
|
||||
def test_task_list_survives_a_progress_lookup_that_fails(
|
||||
self, logged_in_client, mock_api_client,
|
||||
):
|
||||
"""Losing the badges must not lose the page."""
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/recipes/1/tasks": [
|
||||
{"id": 7, "title": "Misura", "order_index": 0, "task_type": "measure"},
|
||||
],
|
||||
"/api/recipes/1": {
|
||||
"id": 1, "code": "REC-001", "name": "Test Recipe",
|
||||
"current_version": {"id": 42},
|
||||
},
|
||||
"/api/production-runs/current": None,
|
||||
"/api/measurements/task-progress": {
|
||||
"error": True, "status_code": 500, "detail": "boom",
|
||||
},
|
||||
})
|
||||
|
||||
resp = logged_in_client.get("/measure/tasks/1")
|
||||
assert resp.status_code == 200
|
||||
assert "Misura" in resp.get_data(as_text=True)
|
||||
|
||||
|
||||
class TestStartSequence:
|
||||
"""GET /measure/start/<recipe_id> — the operator's way in."""
|
||||
|
||||
def test_start_goes_to_the_first_task(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/recipes/1/tasks": [
|
||||
{"id": 31, "title": "Nota", "order_index": 1},
|
||||
{"id": 30, "title": "Prima", "order_index": 0},
|
||||
],
|
||||
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||
})
|
||||
|
||||
resp = logged_in_client.get("/measure/start/1?lot_number=L1")
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith("/measure/execute/30")
|
||||
|
||||
def test_start_remembers_the_traceability(self, logged_in_client, mock_api_client):
|
||||
"""The list used to be where lot and serial were stored; the sequence
|
||||
skips it, so the storing has to happen on the way in."""
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}],
|
||||
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||
})
|
||||
|
||||
logged_in_client.get("/measure/start/1?lot_number=L9&serial_number=S9")
|
||||
with logged_in_client.session_transaction() as sess:
|
||||
assert sess["lot_number"] == "L9"
|
||||
assert sess["serial_number"] == "S9"
|
||||
|
||||
def test_start_refuses_without_the_compulsory_lot(
|
||||
self, logged_in_client, mock_api_client,
|
||||
):
|
||||
"""Point 8 holds on this door too: it is a way into the measurement."""
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}],
|
||||
"/api/recipes/1": {
|
||||
"id": 1, "code": "REC-001", "name": "Test Recipe",
|
||||
"requires_lot": True,
|
||||
},
|
||||
})
|
||||
|
||||
resp = logged_in_client.get("/measure/start/1")
|
||||
assert resp.status_code == 302
|
||||
assert "/measure/select" in resp.headers["Location"]
|
||||
|
||||
def test_start_without_tasks_falls_back_to_the_list(
|
||||
self, logged_in_client, mock_api_client,
|
||||
):
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/recipes/1/tasks": [],
|
||||
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||
})
|
||||
|
||||
resp = logged_in_client.get("/measure/start/1")
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith("/measure/tasks/1")
|
||||
|
||||
|
||||
class TestTaskComplete:
|
||||
"""GET /measure/complete/<recipe_id> tests."""
|
||||
@@ -70,6 +179,66 @@ class TestTaskComplete:
|
||||
assert b"productionClock(" in resp.data
|
||||
|
||||
|
||||
class TestOutOfTolerance:
|
||||
"""Proxies for point 5: the capoturno's answer has to reach the measurement."""
|
||||
|
||||
def test_authorise_forwards_the_credentials(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = {"id": 9, "supervisor_id": 4}
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/measurements/9/authorise",
|
||||
json={"supervisor_username": "capo", "supervisor_password": "segreta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
endpoint, kwargs = mock_api_client.post.call_args
|
||||
assert endpoint[0] == "/api/measurements/9/authorise"
|
||||
assert kwargs["data"]["supervisor_username"] == "capo"
|
||||
|
||||
def test_authorise_needs_credentials(self, logged_in_client, mock_api_client):
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/measurements/9/authorise", json={},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
mock_api_client.post.assert_not_called()
|
||||
|
||||
def test_authorise_propagates_refusal(self, logged_in_client, mock_api_client):
|
||||
"""A operator who is not a supervisor must not authorise their own value."""
|
||||
mock_api_client.post.return_value = {
|
||||
"error": True, "status_code": 403, "detail": "not a supervisor",
|
||||
}
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/measurements/9/authorise",
|
||||
json={"supervisor_username": "tec", "supervisor_password": "x"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_pending_passes_the_scope(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = {"id": 9, "subtask_id": 21}
|
||||
resp = logged_in_client.get(
|
||||
"/measure/api/measurements/pending-authorisation"
|
||||
"?version_id=5&production_run_id=7",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["id"] == 9
|
||||
endpoint, kwargs = mock_api_client.get.call_args
|
||||
assert endpoint[0] == "/api/measurements/pending-authorisation"
|
||||
assert kwargs["params"] == {"version_id": "5", "production_run_id": "7"}
|
||||
|
||||
def test_pending_without_a_run_omits_it(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = None
|
||||
logged_in_client.get(
|
||||
"/measure/api/measurements/pending-authorisation?version_id=5",
|
||||
)
|
||||
assert mock_api_client.get.call_args[1]["params"] == {"version_id": "5"}
|
||||
|
||||
def test_routes_require_login(self, client):
|
||||
for method, url in (
|
||||
("get", "/measure/api/measurements/pending-authorisation?version_id=5"),
|
||||
("post", "/measure/api/measurements/9/authorise"),
|
||||
):
|
||||
resp = getattr(client, method)(url)
|
||||
assert resp.status_code in (302, 401), f"{method} {url}"
|
||||
|
||||
|
||||
class TestSaveMeasurement:
|
||||
"""POST /measure/save-measurement tests."""
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Point 12: the install must work on a network with no way out.
|
||||
|
||||
The factory floor at Tràfilo has no internet. A page that fetches a library from
|
||||
a CDN there does not load slowly, it does not load: the interface never starts.
|
||||
|
||||
The libraries were brought in-house; this is what keeps them there. The failure
|
||||
mode this guards against is not dramatic - someone adds a `<script src="https://
|
||||
cdn...">` because it is the quickest way to try something, it works at their desk,
|
||||
and nobody finds out until the machine is on the shop floor.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
TEMPLATES = APP_ROOT / "templates"
|
||||
STATIC = APP_ROOT / "static"
|
||||
|
||||
# Anything loaded by the browser: src=, href=, url(), importScripts, workerSrc.
|
||||
_EXTERNAL_ASSET_RX = re.compile(
|
||||
r"""(?:src|href)\s*=\s*["']https?://""", re.I,
|
||||
)
|
||||
_EXTERNAL_URL_RX = re.compile(r"""https?://[^\s"'<>)]+""")
|
||||
|
||||
# Hosts a comment may legitimately mention: the check is about what gets fetched,
|
||||
# not about what gets written down. Only documentation links live here.
|
||||
_DOC_HOSTS = (
|
||||
"tailwindcss.com", "github.com", "bugzilla.mozilla.org",
|
||||
"bugs.chromium.org", "bugs.webkit.org", "www.w3.org", "schema.org",
|
||||
"developer.mozilla.org",
|
||||
)
|
||||
|
||||
|
||||
def _tracked_files():
|
||||
"""The files git knows about, or None when git cannot answer.
|
||||
|
||||
Being outside `vendor/` is not the same as being ours. A scratch copy of a
|
||||
library dropped into `static/js/` to debug something looks first-party and is
|
||||
not: it is not in the repository, it never reaches the shop floor, and its
|
||||
comments are not our comments. Trackedness is the honest test for that.
|
||||
|
||||
Trackedness, and not `.gitignore`: an ignore pattern says nothing about a
|
||||
file that is already tracked, so matching patterns would be the wrong
|
||||
question.
|
||||
|
||||
When git cannot answer we scan everything, as before. A guard test that goes
|
||||
quiet the moment it loses its footing is worse than one that cries wolf.
|
||||
"""
|
||||
try:
|
||||
listed = subprocess.run(
|
||||
["git", "ls-files", "-z"],
|
||||
cwd=APP_ROOT, capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return {(APP_ROOT / name).resolve() for name in listed.split("\0") if name}
|
||||
|
||||
|
||||
def _first_party_files(suffixes):
|
||||
tracked = _tracked_files()
|
||||
|
||||
def is_ours(path):
|
||||
return tracked is None or path.resolve() in tracked
|
||||
|
||||
for path in sorted(STATIC.rglob("*")):
|
||||
if path.suffix in suffixes and "vendor" not in path.parts and is_ours(path):
|
||||
yield path
|
||||
for path in sorted(TEMPLATES.rglob("*.html")):
|
||||
if is_ours(path):
|
||||
yield path
|
||||
|
||||
|
||||
def test_no_template_loads_anything_from_the_network():
|
||||
"""A src= or href= pointing outside is the whole bug, in one line."""
|
||||
offenders = []
|
||||
for path in sorted(TEMPLATES.rglob("*.html")):
|
||||
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if _EXTERNAL_ASSET_RX.search(line):
|
||||
offenders.append(f"{path.relative_to(APP_ROOT)}:{n}")
|
||||
assert not offenders, f"asset caricati dalla rete: {offenders}"
|
||||
|
||||
|
||||
def test_no_first_party_script_calls_out():
|
||||
"""Not only tags: a fetch() or a workerSrc reaches the network just the same."""
|
||||
offenders = []
|
||||
for path in _first_party_files({".js", ".css"}):
|
||||
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
for url in _EXTERNAL_URL_RX.findall(line):
|
||||
if any(host in url for host in _DOC_HOSTS):
|
||||
continue
|
||||
offenders.append(f"{path.relative_to(APP_ROOT)}:{n} {url}")
|
||||
assert not offenders, f"riferimenti esterni: {offenders}"
|
||||
|
||||
|
||||
def test_the_libraries_are_actually_in_the_box():
|
||||
"""Repointing the tags without shipping the files is the same white screen."""
|
||||
vendor = STATIC / "vendor"
|
||||
missing = [
|
||||
name for name in (
|
||||
"alpinejs-3.15.12.min.js",
|
||||
"plotly-2.32.0.min.js",
|
||||
"pdf-3.11.174.min.js",
|
||||
"pdf.worker-3.11.174.min.js",
|
||||
"fabric-5.3.1.min.js",
|
||||
"fonts.css",
|
||||
)
|
||||
if not (vendor / name).is_file()
|
||||
]
|
||||
assert not missing, f"librerie dichiarate ma non presenti: {missing}"
|
||||
assert list((vendor / "fonts").glob("*.woff2")), "i font non sono nel pacchetto"
|
||||
|
||||
|
||||
def test_the_pdf_worker_is_local_everywhere_it_is_set():
|
||||
"""The trap of this point: the library loads from disk and its worker does not.
|
||||
|
||||
Everything looks right until someone opens a drawing.
|
||||
"""
|
||||
setters = []
|
||||
for path in sorted(TEMPLATES.rglob("*.html")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if "workerSrc" not in text:
|
||||
continue
|
||||
setters.append(path.relative_to(APP_ROOT).as_posix())
|
||||
for n, line in enumerate(text.splitlines(), 1):
|
||||
if "workerSrc" in line:
|
||||
assert "vendor/pdf.worker" in line, (
|
||||
f"{path.relative_to(APP_ROOT)}:{n} punta il worker fuori casa"
|
||||
)
|
||||
# Four templates render PDFs; if that number changes the new one needs the
|
||||
# worker set too, and this is where you find out.
|
||||
assert len(setters) == 4, f"template che impostano il worker: {setters}"
|
||||
|
||||
|
||||
def test_the_shipped_libraries_are_the_ones_that_were_checked():
|
||||
"""The fingerprints in VERSIONS.md are the record of what was validated.
|
||||
|
||||
A library swapped in place - a hurried patch, a bad merge, something worse -
|
||||
keeps the same filename and the same version in the table. The hash is the
|
||||
only part that notices.
|
||||
"""
|
||||
vendor = STATIC / "vendor"
|
||||
recorded = dict(
|
||||
(name, digest)
|
||||
for digest, name in (
|
||||
line.split(maxsplit=1)
|
||||
for line in (
|
||||
l.strip() for l in
|
||||
(vendor / "VERSIONS.md").read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
if re.fullmatch(r"[0-9a-f]{64}\s+\S+", line)
|
||||
)
|
||||
)
|
||||
assert recorded, "VERSIONS.md non elenca più le impronte"
|
||||
|
||||
import hashlib
|
||||
wrong = []
|
||||
for name, digest in recorded.items():
|
||||
path = vendor / name
|
||||
if not path.is_file():
|
||||
wrong.append(f"{name}: assente")
|
||||
continue
|
||||
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if actual != digest:
|
||||
wrong.append(f"{name}: {actual} != {digest}")
|
||||
assert not wrong, f"librerie diverse da quelle validate: {wrong}"
|
||||
|
||||
|
||||
def test_the_versions_are_pinned():
|
||||
"""No `3.x.x`: a library that updates itself changes an audit system without
|
||||
anyone validating it. The filenames on disk carry the version."""
|
||||
vendor = STATIC / "vendor"
|
||||
unpinned = [
|
||||
p.name for p in vendor.glob("*.js")
|
||||
if not re.search(r"-\d+\.\d+\.\d+(\.min)?\.js$", p.name)
|
||||
]
|
||||
assert not unpinned, f"librerie senza versione nel nome: {unpinned}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The policy that keeps it that way
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
PAGES = ["/auth/login", "/"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PAGES)
|
||||
def test_every_page_carries_the_policy(client, path):
|
||||
resp = client.get(path, follow_redirects=False)
|
||||
csp = resp.headers.get("Content-Security-Policy", "")
|
||||
assert "default-src 'self'" in csp
|
||||
assert "connect-src 'self'" in csp
|
||||
# No CDN host may be allowed back in through the policy itself.
|
||||
assert "http://" not in csp and "https://" not in csp
|
||||
|
||||
|
||||
def test_the_policy_allows_what_the_app_actually_needs(client):
|
||||
"""A policy that breaks the interface gets removed, and then it protects
|
||||
nothing. These are the four allowances the libraries genuinely require."""
|
||||
csp = client.get("/auth/login").headers["Content-Security-Policy"]
|
||||
assert "'unsafe-eval'" in csp # Alpine 3, Plotly
|
||||
assert "'unsafe-inline'" in csp # componenti Alpine nei template
|
||||
assert "worker-src 'self' blob:" in csp # worker di PDF.js
|
||||
assert "img-src 'self' data: blob:" in csp # canvas Fabric, miniature
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Point 10: the operator's way through a recipe.
|
||||
|
||||
The sequence is the normal path and the list is a place you go on purpose. What
|
||||
these tests hold on to is the part that is easy to undo by accident: the way in
|
||||
lands on a task, the list says which task was left unfinished and lets the
|
||||
operator back into it, «Fine ciclo misura» is visible before it is usable, and
|
||||
nothing in any of it opens a door to the statistics (point 15).
|
||||
"""
|
||||
from tests.conftest import api_get_router
|
||||
|
||||
TASK = {
|
||||
"id": 11,
|
||||
"recipe_id": 3,
|
||||
"version_id": 5,
|
||||
"task_type": "measure",
|
||||
"title": "Quota",
|
||||
"directive": None,
|
||||
"description": None,
|
||||
"file_path": None,
|
||||
"file_type": None,
|
||||
"annotations_json": None,
|
||||
"subtasks": [{
|
||||
"id": 21, "marker_number": 1, "description": "Altezza", "order_index": 0,
|
||||
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
|
||||
"unit": "mm",
|
||||
}],
|
||||
}
|
||||
|
||||
RECIPE = {
|
||||
"id": 3, "code": "REC-1", "name": "Ricetta",
|
||||
"measurement_interval_minutes": 30, "allow_manual_input": True,
|
||||
"current_version": {"id": 5, "version_number": 1},
|
||||
}
|
||||
|
||||
|
||||
def _italian(client):
|
||||
with client.session_transaction() as sess:
|
||||
sess["language"] = "it"
|
||||
|
||||
|
||||
def _operator_only(client):
|
||||
"""Strip the Maker role: this is what the shop floor account looks like."""
|
||||
with client.session_transaction() as sess:
|
||||
user = dict(sess["user"])
|
||||
user["roles"] = ["MeasurementTec"]
|
||||
user["is_admin"] = False
|
||||
sess["user"] = user
|
||||
|
||||
|
||||
def _execute_page(client, mock_api_client):
|
||||
_italian(client)
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/tasks/11": TASK,
|
||||
"/api/recipes/3/tasks": [{"id": 11, "order_index": 0}],
|
||||
"/api/recipes/3": RECIPE,
|
||||
})
|
||||
resp = client.get("/measure/execute/11")
|
||||
assert resp.status_code == 200
|
||||
return resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def _list_page(client, mock_api_client, progress=None):
|
||||
_italian(client)
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/recipes/3/tasks": [
|
||||
{"id": 11, "title": "Misura", "order_index": 0, "task_type": "measure"},
|
||||
],
|
||||
"/api/recipes/3": RECIPE,
|
||||
"/api/production-runs/current": None,
|
||||
"/api/measurements/task-progress": {"tasks": progress or []},
|
||||
})
|
||||
resp = client.get("/measure/tasks/3")
|
||||
assert resp.status_code == 200
|
||||
return resp.get_data(as_text=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The way in: a recipe opens on a task, not on a list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_selection_screen_starts_the_sequence(
|
||||
logged_in_client, mock_api_client, monkeypatch,
|
||||
):
|
||||
_italian(logged_in_client)
|
||||
# The page refuses to render without a station, which is the right answer on a
|
||||
# real terminal and only an obstacle here.
|
||||
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||
mock_api_client.get_station_recipes.return_value = []
|
||||
resp = logged_in_client.get("/measure/select")
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
assert "'/measure/start/'" in html
|
||||
# The full list is still reachable, deliberately as the quiet option.
|
||||
assert "'/measure/tasks/'" in html
|
||||
|
||||
|
||||
def test_avvia_on_the_list_starts_the_sequence(logged_in_client, mock_api_client):
|
||||
html = _list_page(logged_in_client, mock_api_client)
|
||||
assert "/measure/start/3" in html
|
||||
assert "AVVIA IN SEQUENZA" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The list: second level, and it says what was left unfinished
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_operator_can_open_a_single_task(logged_in_client, mock_api_client):
|
||||
"""Without this, «incompiuto» would be a label with no way to act on it."""
|
||||
_operator_only(logged_in_client)
|
||||
html = _list_page(logged_in_client, mock_api_client)
|
||||
assert "Visualizza singolo TASK" in html
|
||||
assert "/measure/execute/11" in html
|
||||
|
||||
|
||||
def test_a_half_measured_task_is_marked(logged_in_client, mock_api_client):
|
||||
html = _list_page(
|
||||
logged_in_client, mock_api_client,
|
||||
progress=[{"task_id": 11, "quotes": 3, "measured": 2, "state": "partial"}],
|
||||
)
|
||||
assert "Incompiuto" in html
|
||||
assert "2/3" in html
|
||||
|
||||
|
||||
def test_a_finished_task_is_marked(logged_in_client, mock_api_client):
|
||||
html = _list_page(
|
||||
logged_in_client, mock_api_client,
|
||||
progress=[{"task_id": 11, "quotes": 3, "measured": 3, "state": "complete"}],
|
||||
)
|
||||
assert "Completato" in html
|
||||
assert "3/3" in html
|
||||
|
||||
|
||||
def test_an_untouched_task_says_nothing(logged_in_client, mock_api_client):
|
||||
"""A list that shouts at every row is a list nobody reads."""
|
||||
html = _list_page(
|
||||
logged_in_client, mock_api_client,
|
||||
progress=[{"task_id": 11, "quotes": 3, "measured": 0, "state": "none"}],
|
||||
)
|
||||
assert "Incompiuto" not in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inside the task: «Completato» forward, «Fine ciclo misura» visible but shut
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_header_offers_completato_not_the_summary(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
html = _execute_page(logged_in_client, mock_api_client)
|
||||
assert 'x-data="taskExecute()"' in html
|
||||
# The header control moves the operator on and is gated by the same rule as
|
||||
# the footer, rather than jumping out of the sequence to a recap.
|
||||
assert 'canLeaveTask' in html
|
||||
assert '@click="goToNextTask()"' in html
|
||||
|
||||
|
||||
def test_fine_ciclo_is_shown_before_it_can_be_used(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
"""It used to appear only once the task was already finished."""
|
||||
html = _execute_page(logged_in_client, mock_api_client)
|
||||
assert 'x-show="!cycleConfirmed"' in html
|
||||
assert ':disabled="!isComplete"' in html
|
||||
assert "Fine ciclo misura" in html
|
||||
|
||||
|
||||
def test_the_missing_quotes_are_counted_out_loud(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
html = _execute_page(logged_in_client, mock_api_client)
|
||||
assert "missingQuotesLabel" in html
|
||||
assert "Mancano" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 15: none of this may open a way to the statistics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_operator_flow_never_links_to_the_statistics(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
_operator_only(logged_in_client)
|
||||
pages = [
|
||||
_list_page(logged_in_client, mock_api_client),
|
||||
_execute_page(logged_in_client, mock_api_client),
|
||||
]
|
||||
for html in pages:
|
||||
assert "/statistics" not in html
|
||||
@@ -0,0 +1,213 @@
|
||||
"""The screens that carry the rules of a recipe (points 8, 9 and 11).
|
||||
|
||||
The server refuses what the recipe forbids; these tests are about the operator
|
||||
being told before the refusal rather than after it - a keypad that is not there,
|
||||
a start button that will not start, a description that reads the way it was
|
||||
written.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 11 - line breaks and bold, and nothing else
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render(flask_app, template_string, **context):
|
||||
with flask_app.test_request_context():
|
||||
return flask_app.jinja_env.from_string(template_string).render(**context)
|
||||
|
||||
|
||||
def test_rich_text_keeps_line_breaks(flask_app):
|
||||
out = _render(flask_app, "{{ v|rich_text }}", v="prima\nseconda")
|
||||
assert out == "prima<br>seconda"
|
||||
|
||||
|
||||
def test_rich_text_makes_bold(flask_app):
|
||||
out = _render(flask_app, "{{ v|rich_text }}", v="quota **critica** qui")
|
||||
assert out == "quota <strong>critica</strong> qui"
|
||||
|
||||
|
||||
def test_rich_text_escapes_everything_else(flask_app):
|
||||
"""No HTML is accepted, so there is nothing to sanitise away later."""
|
||||
out = _render(flask_app, "{{ v|rich_text }}", v="<script>alert(1)</script>")
|
||||
assert "<script>" not in out
|
||||
assert "<script>" in out
|
||||
|
||||
|
||||
def test_rich_text_cannot_be_tricked_into_tags(flask_app):
|
||||
"""Bold markers around a tag must not reassemble it."""
|
||||
out = _render(flask_app, "{{ v|rich_text }}", v="**<b onclick='x'>ciao</b>**")
|
||||
assert "onclick" not in out or "'" in out
|
||||
assert "<b " not in out
|
||||
assert out.startswith("<strong><b")
|
||||
|
||||
|
||||
def test_rich_text_of_nothing_is_nothing(flask_app):
|
||||
assert _render(flask_app, "{{ v|rich_text }}", v=None) == ""
|
||||
assert _render(flask_app, "{{ v|rich_text }}", v="") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 9 - the keypad is not offered when the recipe forbids typing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
TASK = {
|
||||
"id": 11,
|
||||
"recipe_id": 3,
|
||||
"version_id": 5,
|
||||
"task_type": "measure",
|
||||
"title": "Quota",
|
||||
"directive": None,
|
||||
"description": "prima riga\n**grassetto**",
|
||||
"file_path": None,
|
||||
"file_type": None,
|
||||
"annotations_json": None,
|
||||
"subtasks": [{
|
||||
"id": 21, "marker_number": 1, "description": "Altezza", "order_index": 0,
|
||||
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
|
||||
"unit": "mm",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def _execute_page(client, mock_api_client, allow_manual):
|
||||
mock_api_client.get.side_effect = [
|
||||
TASK,
|
||||
[{"id": 11, "order_index": 0}],
|
||||
{
|
||||
"id": 3, "measurement_interval_minutes": 30,
|
||||
"allow_manual_input": allow_manual,
|
||||
},
|
||||
]
|
||||
resp = client.get("/measure/execute/11")
|
||||
assert resp.status_code == 200
|
||||
return resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_keypad_digits_are_absent_when_typing_is_forbidden(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
html = _execute_page(logged_in_client, mock_api_client, allow_manual=False)
|
||||
assert "allowManual: false" in html
|
||||
assert "addDigit('7')" not in html, "i tasti numerici non devono essere in pagina"
|
||||
# The corrections and the confirm stay: a wrong caliper reading has to be
|
||||
# clearable, and the value still has to be confirmable.
|
||||
assert "clearAll()" in html
|
||||
assert "confirm()" in html
|
||||
|
||||
|
||||
def test_keypad_is_there_when_the_recipe_allows_typing(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
html = _execute_page(logged_in_client, mock_api_client, allow_manual=True)
|
||||
assert "allowManual: true" in html
|
||||
assert "addDigit('7')" in html
|
||||
|
||||
|
||||
def test_keypad_stays_when_the_recipe_cannot_be_read(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
"""A recipe that failed to load must not silently take the keypad away."""
|
||||
mock_api_client.get.side_effect = [
|
||||
TASK,
|
||||
[{"id": 11, "order_index": 0}],
|
||||
{"error": True, "detail": "boom"},
|
||||
]
|
||||
resp = logged_in_client.get("/measure/execute/11")
|
||||
assert resp.status_code == 200
|
||||
assert "allowManual: true" in resp.get_data(as_text=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 11 on the measurement screen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_operator_sees_the_description_formatted(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
"""It was written for the operator, and until now only the maker saw it."""
|
||||
html = _execute_page(logged_in_client, mock_api_client, allow_manual=True)
|
||||
assert "prima riga<br><strong>grassetto</strong>" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 8 - the start control knows what the recipe demands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _task_list(client, mock_api_client, recipe, session_lot=None):
|
||||
# The fixture session is English; these assertions read the Italian source
|
||||
# strings, so the locale is pinned rather than assumed.
|
||||
with client.session_transaction() as sess:
|
||||
sess["language"] = "it"
|
||||
if session_lot is not None:
|
||||
sess["lot_number"] = session_lot
|
||||
mock_api_client.get.side_effect = [
|
||||
recipe,
|
||||
[{"id": 11, "title": "Task", "order_index": 0, "task_type": "measure"}],
|
||||
]
|
||||
resp = client.get("/measure/tasks/3")
|
||||
assert resp.status_code == 200
|
||||
return resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_avvia_does_not_start_without_the_required_lot(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
"""This page is reachable directly, so the rule has to hold here too."""
|
||||
html = _task_list(
|
||||
logged_in_client, mock_api_client,
|
||||
{"id": 3, "code": "REC-1", "name": "Con lotto", "requires_lot": True},
|
||||
)
|
||||
assert "Compila prima" in html
|
||||
# The way in is closed, not merely greyed: it leads back to where the lot is
|
||||
# asked for. (The per-task links below belong to the Maker's own view.)
|
||||
assert "/measure/select?recipe=REC-1" in html
|
||||
|
||||
|
||||
def test_avvia_starts_once_the_lot_is_there(logged_in_client, mock_api_client):
|
||||
html = _task_list(
|
||||
logged_in_client, mock_api_client,
|
||||
{"id": 3, "code": "REC-1", "name": "Con lotto", "requires_lot": True},
|
||||
session_lot="LOT-1",
|
||||
)
|
||||
assert "Compila prima" not in html
|
||||
assert "/measure/select?recipe=REC-1" not in html
|
||||
|
||||
|
||||
def test_avvia_is_untouched_when_nothing_is_required(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
html = _task_list(
|
||||
logged_in_client, mock_api_client,
|
||||
{"id": 3, "code": "REC-1", "name": "Libera"},
|
||||
)
|
||||
assert "Compila prima" not in html
|
||||
assert "/measure/select?recipe=REC-1" not in html
|
||||
|
||||
|
||||
def test_selection_screen_carries_the_traceability_rules(
|
||||
logged_in_client, mock_api_client, monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
||||
import importlib
|
||||
|
||||
import config
|
||||
importlib.reload(config)
|
||||
import blueprints.measure
|
||||
importlib.reload(blueprints.measure)
|
||||
|
||||
mock_api_client.get_station_recipes.return_value = [
|
||||
{
|
||||
"id": 1, "code": "REC-001", "name": "Con lotto",
|
||||
"requires_lot": True, "requires_serial": False,
|
||||
},
|
||||
]
|
||||
resp = logged_in_client.get("/measure/select")
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
assert "missingTraceability" in html
|
||||
# The rule travels with the recipe data the cards are built from.
|
||||
assert "requires_lot" in html
|
||||
@@ -24,6 +24,8 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import api_get_router
|
||||
|
||||
|
||||
# Match every inline <script>…</script> (i.e. without `src=`) so we don't try
|
||||
# to syntax-check Alpine.js / Plotly bundles served from a CDN.
|
||||
@@ -266,6 +268,39 @@ def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api):
|
||||
_check_alpine_attributes(html, "/measure/execute")
|
||||
|
||||
|
||||
def test_select_recipe_inline_js_is_valid(logged_in_client, mock_measure_api, monkeypatch):
|
||||
"""The selection screen decides whether Avvia starts at all.
|
||||
|
||||
Its expressions are inline in x-data, so a broken one there takes the search,
|
||||
the barcode scanner and the traceability check down with it.
|
||||
"""
|
||||
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
||||
import importlib
|
||||
|
||||
import config
|
||||
importlib.reload(config)
|
||||
import blueprints.measure
|
||||
importlib.reload(blueprints.measure)
|
||||
|
||||
_force_italian(logged_in_client)
|
||||
mock_measure_api.get_station_recipes.return_value = [
|
||||
{
|
||||
"id": 1, "code": "REC-001", "name": "Ricetta con l'apostrofo",
|
||||
"description": "Con l'apostrofo anche qui",
|
||||
"requires_lot": True, "requires_serial": True,
|
||||
},
|
||||
]
|
||||
|
||||
resp = logged_in_client.get("/measure/select")
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
|
||||
for i, body in enumerate(_INLINE_SCRIPT_RX.findall(html)):
|
||||
_node_check(body, f"/measure/select script[{i}]")
|
||||
|
||||
_check_alpine_attributes(html, "/measure/select")
|
||||
|
||||
|
||||
def test_task_list_inline_js_is_valid(logged_in_client, mock_measure_api):
|
||||
"""The task list carries the shared measurement clock.
|
||||
|
||||
@@ -274,13 +309,20 @@ def test_task_list_inline_js_is_valid(logged_in_client, mock_measure_api):
|
||||
with it, and the operator would simply never be called back.
|
||||
"""
|
||||
_force_italian(logged_in_client)
|
||||
mock_measure_api.get.side_effect = [
|
||||
{"id": 3, "code": "REC-3", "name": "Ricetta", "description": None},
|
||||
[{
|
||||
mock_measure_api.get.side_effect = api_get_router({
|
||||
"/api/recipes/3/tasks": [{
|
||||
"id": 11, "order_index": 0, "title": "Quota d'ingresso",
|
||||
"task_type": "measure", "subtasks": [], "file_path": None,
|
||||
}],
|
||||
]
|
||||
"/api/recipes/3": {
|
||||
"id": 3, "code": "REC-3", "name": "Ricetta", "description": None,
|
||||
"current_version": {"id": 5},
|
||||
},
|
||||
"/api/production-runs/current": None,
|
||||
"/api/measurements/task-progress": {
|
||||
"tasks": [{"task_id": 11, "quotes": 2, "measured": 1, "state": "partial"}],
|
||||
},
|
||||
})
|
||||
|
||||
resp = logged_in_client.get("/measure/tasks/3")
|
||||
assert resp.status_code == 200
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
"""Graph plus image in, numbers out.
|
||||
|
||||
The one implementation of vision execution: the worker beside the server and
|
||||
the station agent both call this, and they must agree to the digit. No HTTP, no
|
||||
database, no filesystem beyond reading the pinned submodule's commit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from visionsuite.core.image import Image
|
||||
from visionsuite.task import Task
|
||||
|
||||
_SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolFailure:
|
||||
tool_id: str
|
||||
tool_name: str
|
||||
error: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunOutcome:
|
||||
outputs: dict[str, float] = field(default_factory=dict)
|
||||
failures: list[ToolFailure] = field(default_factory=list)
|
||||
engine_version: str = ""
|
||||
duration_ms: int = 0
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _engine_version_from_git() -> str:
|
||||
"""Read the pinned commit from the submodule checkout.
|
||||
|
||||
Cached because the subprocess call is not free and the answer cannot
|
||||
change within a run. Kept separate from `engine_version()` so that the
|
||||
(cheap) environment-variable check below is never served a stale answer,
|
||||
and so a test can force this path to fail without poisoning the cache
|
||||
for real callers.
|
||||
"""
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "-C", str(_SUBMODULE), "rev-parse", "HEAD"],
|
||||
text=True,
|
||||
stderr=subprocess.PIPE,
|
||||
).strip()
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
raise RuntimeError(
|
||||
"engine_version: cannot determine which VisionSuite commit this "
|
||||
"process is running. VISION_ENGINE_VERSION is not set, and "
|
||||
f"`git -C {_SUBMODULE} rev-parse HEAD` failed ({exc}). "
|
||||
"In the vision worker container this is expected - it has no "
|
||||
"usable .git for the vendored submodule - so the image must be "
|
||||
"built with `--build-arg VISION_ENGINE_VERSION=<commit>` "
|
||||
"(see Dockerfile.vision). In a development checkout, make sure "
|
||||
"the vendor/visionsuite submodule is initialised. Refusing to "
|
||||
"guess: a station must not measure with an engine it cannot name."
|
||||
) from exc
|
||||
|
||||
|
||||
def engine_version() -> str:
|
||||
"""The pinned VisionSuite commit: stamped on every number produced.
|
||||
|
||||
Resolution order: the VISION_ENGINE_VERSION environment variable (set at
|
||||
container build time - Ruling R8, see Dockerfile.vision) if set and
|
||||
non-empty; otherwise `git rev-parse HEAD` on the vendored submodule,
|
||||
which works in a development checkout. Never falls back to a guess, an
|
||||
empty string, or "unknown" - a station running an engine it cannot name
|
||||
must refuse to measure rather than produce numbers nobody can trace.
|
||||
"""
|
||||
stamped = os.environ.get("VISION_ENGINE_VERSION", "").strip()
|
||||
if stamped:
|
||||
return stamped
|
||||
return _engine_version_from_git()
|
||||
|
||||
|
||||
def run_graph(
|
||||
image: Any,
|
||||
graph: dict,
|
||||
calibration: dict | None = None,
|
||||
) -> RunOutcome:
|
||||
"""Run a serialised vs-task graph against one image.
|
||||
|
||||
A tool that fails does not stop the others - on a crooked piece an edge may
|
||||
simply not be there, and the other quotes are still wanted. Failures come
|
||||
back named, they are not swallowed.
|
||||
|
||||
`image` is whatever the caller already has in hand: a plain numpy array is
|
||||
wrapped into the `visionsuite.core.image.Image` that vs-measure's tools
|
||||
actually read via `.pixels`; an `Image` passed in is used as-is.
|
||||
"""
|
||||
task = Task.from_dict(graph)
|
||||
wrapped_image = image if isinstance(image, Image) else Image(pixels=np.asarray(image))
|
||||
context: dict[str, Any] = {"image": wrapped_image}
|
||||
if calibration is not None:
|
||||
context["calibration"] = calibration
|
||||
|
||||
started = time.monotonic()
|
||||
report = task.run(context=context)
|
||||
elapsed_ms = int((time.monotonic() - started) * 1000)
|
||||
|
||||
outputs: dict[str, float] = {}
|
||||
failures: list[ToolFailure] = []
|
||||
for result in report:
|
||||
if not result.ok:
|
||||
failures.append(ToolFailure(
|
||||
tool_id=result.tool_id,
|
||||
tool_name=result.tool_name,
|
||||
error=result.error or "unknown failure",
|
||||
))
|
||||
continue
|
||||
for name, value in result.outputs.items():
|
||||
if isinstance(value, (int, float)):
|
||||
outputs[f"{result.tool_id}.{name}"] = float(value)
|
||||
|
||||
return RunOutcome(
|
||||
outputs=outputs,
|
||||
failures=failures,
|
||||
engine_version=engine_version(),
|
||||
duration_ms=elapsed_ms,
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""The runner: graph plus image in, numbers out. No HTTP, no database."""
|
||||
import pytest
|
||||
|
||||
# The vision tree requires Python >=3.13 (vs-task/vs-measure/vs-pm2d declare it
|
||||
# upstream, see pyproject.toml's `vision` extra), while backend and frontend
|
||||
# stay on 3.11. This must run before `import numpy` - numpy itself is only
|
||||
# installed alongside the vision extra - so that `uv run pytest` on the
|
||||
# default 3.11 environment reports this module as skipped, with a reason,
|
||||
# instead of erroring out of the whole collection.
|
||||
pytest.importorskip(
|
||||
"visionsuite.task",
|
||||
reason=(
|
||||
"the vision tree requires Python >=3.13 (vs-task/vs-measure/vs-pm2d "
|
||||
"declare it upstream); run it with "
|
||||
"`uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests`"
|
||||
),
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
|
||||
from src.vision import runner
|
||||
from src.vision.runner import RunOutcome, engine_version, run_graph
|
||||
|
||||
|
||||
def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
|
||||
"""A light disc on a dark ground: a fake part, but a measurable one."""
|
||||
image = np.zeros((size, size), dtype=np.uint8)
|
||||
yy, xx = np.ogrid[:size, :size]
|
||||
centre = size // 2
|
||||
image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255
|
||||
return image
|
||||
|
||||
|
||||
def a_circle_graph() -> dict:
|
||||
"""A two-tool graph, built with vs-task's own API."""
|
||||
from visionsuite.task import Task
|
||||
from visionsuite.task.tools import DetectCircleTool, DiameterTool
|
||||
|
||||
task = Task(name="prova")
|
||||
found = task.add(DetectCircleTool(name="disco"))
|
||||
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
|
||||
found.scan_size = 30.0
|
||||
|
||||
quota = task.add(DiameterTool(name="diametro"))
|
||||
quota.link("circle", found, "circle")
|
||||
return task.to_dict()
|
||||
|
||||
|
||||
def test_the_graph_produces_named_outputs():
|
||||
outcome = run_graph(a_disc(), a_circle_graph())
|
||||
|
||||
assert isinstance(outcome, RunOutcome)
|
||||
assert outcome.failures == []
|
||||
# Keys are "tool id dot output name" - the same shape a subtask stores in
|
||||
# vision_output.
|
||||
assert any(key.endswith(".diameter") for key in outcome.outputs)
|
||||
diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".diameter"))
|
||||
assert diameter == pytest.approx(120.0, abs=4.0)
|
||||
|
||||
|
||||
def test_a_broken_tool_does_not_sink_the_others():
|
||||
"""vs-task does not stop the run on an error: the runner reports it instead."""
|
||||
graph = a_circle_graph()
|
||||
graph["tools"].append(
|
||||
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
|
||||
"properties": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
|
||||
)
|
||||
|
||||
outcome = run_graph(a_disc(), graph)
|
||||
|
||||
assert [f.tool_id for f in outcome.failures] == ["ghost"]
|
||||
assert any(key.endswith(".diameter") for key in outcome.outputs)
|
||||
|
||||
|
||||
def test_the_engine_says_which_version_it_is():
|
||||
version = engine_version()
|
||||
assert len(version) == 40 # a full git SHA
|
||||
|
||||
|
||||
def test_engine_version_honours_the_environment_variable(monkeypatch):
|
||||
"""A build-stamped VISION_ENGINE_VERSION wins over git, if set (Ruling R8:
|
||||
the container has no usable .git for the vendored submodule, so the
|
||||
worker image stamps the commit at build time instead of discovering it)."""
|
||||
stamped = "a" * 40
|
||||
monkeypatch.setenv("VISION_ENGINE_VERSION", stamped)
|
||||
runner._engine_version_from_git.cache_clear()
|
||||
|
||||
try:
|
||||
assert engine_version() == stamped
|
||||
finally:
|
||||
runner._engine_version_from_git.cache_clear()
|
||||
|
||||
|
||||
def test_engine_version_raises_rather_than_inventing_one(monkeypatch):
|
||||
"""No env var and no resolvable git HEAD: refuse loudly. An engine that
|
||||
cannot say what it is has no business producing measurements - it must
|
||||
never fall back to a guess, an empty string, or "unknown"."""
|
||||
monkeypatch.delenv("VISION_ENGINE_VERSION", raising=False)
|
||||
runner._engine_version_from_git.cache_clear()
|
||||
|
||||
def fake_check_output(*args, **kwargs):
|
||||
raise FileNotFoundError("git: command not found")
|
||||
|
||||
monkeypatch.setattr(runner.subprocess, "check_output", fake_check_output)
|
||||
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="VISION_ENGINE_VERSION"):
|
||||
engine_version()
|
||||
finally:
|
||||
runner._engine_version_from_git.cache_clear()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""The heavy container: the runner behind an internal API.
|
||||
|
||||
Separate from the FastAPI server on purpose. The API image stays light, a
|
||||
VisionSuite upgrade does not restart production traffic, and an execution that
|
||||
crashes does not take the other tablets' requests down with it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
||||
from PIL import Image
|
||||
|
||||
from src.vision.runner import engine_version, run_graph
|
||||
|
||||
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok", "engine_version": engine_version()}
|
||||
|
||||
|
||||
@app.post("/run")
|
||||
async def run(
|
||||
image: UploadFile = File(...),
|
||||
graph: str = Form(...),
|
||||
) -> dict:
|
||||
try:
|
||||
parsed_graph = json.loads(graph)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"graph is not valid JSON: {exc}",
|
||||
) from exc
|
||||
|
||||
frame = np.array(Image.open(image.file).convert("L"))
|
||||
|
||||
try:
|
||||
outcome = run_graph(frame, parsed_graph)
|
||||
except ValueError as exc:
|
||||
# from_dict refuses a schema version it does not handle; that is a bad
|
||||
# request, not a server fault.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
return {
|
||||
"outputs": outcome.outputs,
|
||||
"failures": [
|
||||
{"tool_id": f.tool_id, "tool_name": f.tool_name, "error": f.error}
|
||||
for f in outcome.failures
|
||||
],
|
||||
"engine_version": outcome.engine_version,
|
||||
"duration_ms": outcome.duration_ms,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"""The worker is a thin shell: it receives an image and a graph, and answers
|
||||
with numbers."""
|
||||
import io
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
# Same reasoning as src/vision/tests/test_runner.py: the vision tree (and
|
||||
# therefore this worker, which imports src.vision.runner) requires Python
|
||||
# >=3.13. This must run before `import numpy` / `from src.vision_worker.main
|
||||
# import app` so that `uv run pytest` on the default 3.11 environment reports
|
||||
# this module as skipped, with a reason, instead of erroring out of
|
||||
# collection.
|
||||
pytest.importorskip(
|
||||
"visionsuite.task",
|
||||
reason=(
|
||||
"the vision tree requires Python >=3.13 (vs-task/vs-measure/vs-pm2d "
|
||||
"declare it upstream); run it with "
|
||||
"`uv run --python 3.13 --extra vision-worker --extra dev pytest "
|
||||
"src/vision_worker/tests`"
|
||||
),
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from PIL import Image
|
||||
|
||||
from src.vision_worker.main import app
|
||||
|
||||
|
||||
def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
|
||||
image = np.zeros((size, size), dtype=np.uint8)
|
||||
yy, xx = np.ogrid[:size, :size]
|
||||
centre = size // 2
|
||||
image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255
|
||||
buffer = io.BytesIO()
|
||||
Image.fromarray(image).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def a_circle_graph() -> dict:
|
||||
from visionsuite.task import Task
|
||||
from visionsuite.task.tools import DetectCircleTool, DiameterTool
|
||||
|
||||
task = Task(name="prova")
|
||||
found = task.add(DetectCircleTool(name="disco"))
|
||||
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
|
||||
found.scan_size = 30.0
|
||||
quota = task.add(DiameterTool(name="diametro"))
|
||||
quota.link("circle", found, "circle")
|
||||
return task.to_dict()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_says_which_engine_it_carries():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||
response = await client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["engine_version"]) == 40
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_returns_the_named_outputs():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||
response = await client.post(
|
||||
"/run",
|
||||
files={"image": ("piece.png", a_disc_png(), "image/png")},
|
||||
data={"graph": json.dumps(a_circle_graph())},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["failures"] == []
|
||||
assert any(key.endswith(".diameter") for key in body["outputs"])
|
||||
# Pinned to src/vision/tests/test_runner.py's in-process expectation
|
||||
# (same graph, same image, same tolerance) - the whole point of a shared
|
||||
# engine_version is that the number does not depend on which path
|
||||
# computed it.
|
||||
diameter = next(v for k, v in body["outputs"].items() if k.endswith(".diameter"))
|
||||
assert diameter == pytest.approx(120.0, abs=4.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_malformed_graph_is_refused_not_crashed():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||
response = await client.post(
|
||||
"/run",
|
||||
files={"image": ("piece.png", a_disc_png(), "image/png")},
|
||||
data={"graph": json.dumps({"schema_version": 99, "tools": []})},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "99" in response.json()["detail"]
|
||||
@@ -941,6 +941,35 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/97/f6de8d4af54d6401d6581a686cce3e3e2371a79ba459a449104e026c08bc/kaleido-1.2.0-py3-none-any.whl", hash = "sha256:c27ed82b51df6b923d0e656feac221343a0dbcd2fb9bc7e6b1db97f61e9a1513", size = 68997, upload-time = "2025-11-04T21:24:21.704Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llvmlite"
|
||||
version = "0.49.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/27/72ae94ea5c8f7349ec1c229d4cd058feb799cbd0833ad6d1b47c919b37b7/llvmlite-0.49.0.tar.gz", hash = "sha256:00f16db782f4a13c78c5804aedc434e46794a77e89999a168f9401106270e50a", size = 194467, upload-time = "2026-08-11T16:26:00.489Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/d0/ab52de2328e97ca96cdf0331a5f774796bddc420a51768f4501193f80cbb/llvmlite-0.49.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:4b0e710880b7cc910392bd6b9f1bbf468fed99b182e4420d51598f36114b3dce", size = 40479230, upload-time = "2026-08-11T16:23:28.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/80/0989432d12b7c86a6f5f380eb92eca7de779af9b34dedbd311b694d7da8d/llvmlite-0.49.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a8c0fc9d624bdc30a3d2db11eb2fb98f80fb209d20b37604eda516cd9b699cf4", size = 59890659, upload-time = "2026-08-11T16:23:37.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/e9/76859ca36aaa460b6ae0508e01637f0e9bdb9b59faaa4637ade3b94bbcca/llvmlite-0.49.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20496a5c9fdb8179fb9300e7d19f6782555d98aeeb4a322264aa7fd99f980618", size = 58344482, upload-time = "2026-08-11T16:23:44.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/49/47cd23e05d52d117b6119871ec299adedc9d8d332a2296964d9b2adc06d9/llvmlite-0.49.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a5b06c1b5fc4ae4c9b169b065f42b719448ef1f873687ef224ef69969b75ec3", size = 41865253, upload-time = "2026-08-11T16:23:50.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ae/3f699ebe3590e15e023a6372dd147526fd8ec398aacf9ceb844e854964a8/llvmlite-0.49.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:b541c8fac3450db7574d1f53cf9dff83f285bfed9d69bf81fe71fc2a7d4f97fe", size = 40479231, upload-time = "2026-08-11T16:23:56.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/3c/e97f69c62a2d972066d9a2612ce1f3de313035ac897a5b9f787cad8b55f7/llvmlite-0.49.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6acba646d88abbc87d5c113a3d62c1fbf8b8fee11c6493f516803e30f21ae870", size = 59890658, upload-time = "2026-08-11T16:24:05.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/e6/e942ee08605fc0526ff3854260c384d8315a5830e16c4c2a5aebc14dc9bf/llvmlite-0.49.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec8ad805e7515cb8440a690eb3cef4d34acb29eef80b705ec4e1c1ad3c43c68", size = 58344481, upload-time = "2026-08-11T16:24:13.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/49/2a44871cac6b5a2fd4aabd68cfdaf6de9a5c7edb36dee5d47b77bda4b50f/llvmlite-0.49.0-cp312-cp312-win_amd64.whl", hash = "sha256:3a9c9e3af4e214acfefa4f73ebe7bc3fb35854a62b654edb3953f5ae33c08ba3", size = 41865543, upload-time = "2026-08-11T16:24:20.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/85/0b536a3c59f2636d9dd51dda832b6c1d0ffec37608429dedf128664918f1/llvmlite-0.49.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:039fa4054a06f537fb39248d4472284ca96be311a142ec09e69f95630ab469cc", size = 40479230, upload-time = "2026-08-11T16:24:27.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/1c/ca8ba47b057b793099784475499771780ec46839f2782f753a7079d23520/llvmlite-0.49.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddc7aecd4f56397ed6e8f120ec5dcd5a1a8f0e6032ca4af413462792d4dca2e3", size = 59890659, upload-time = "2026-08-11T16:24:35.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/af/9526dfdd33a923f33e29a18b8f9801ee7ee4b7397e88d28192c1024c4a75/llvmlite-0.49.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3dee64784201b64c13a8df62c48a4f4218858faaa65889866bb29bdc243c038", size = 58344482, upload-time = "2026-08-11T16:24:45.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/7f/9f5afcf6476b228d6b170408f377a0c4f91477fc1fc91f8141088b45bf46/llvmlite-0.49.0-cp313-cp313-win_amd64.whl", hash = "sha256:a1b414dc6b164738ec39dd8987cea73829057b7dd92fc6d91b52838385fc1dd2", size = 41865544, upload-time = "2026-08-11T16:24:53.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d1/16599b8c9f21802448059482eab48a9e74086dc56b901a677ba355565e64/llvmlite-0.49.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:80a84683d04516bb51da1bbeebddaf2c2f558809c93078a8f91807909ae331f8", size = 40479230, upload-time = "2026-08-11T16:25:01.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/61/0b23849141a4c4e7091fcd158ebb45195896974bebca3e58fee7cad4b4f4/llvmlite-0.49.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4281a0171d66d2098adce4ba706b8c550b1b10718650f682d64cde16e84e4de5", size = 59890659, upload-time = "2026-08-11T16:25:08.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/92/628692b74b31e27af9ba7e8ba651941ee4956959d5478123c453f59aad4a/llvmlite-0.49.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b095f15fb12c4d90495df5b1a3772b4732cc408398b204a787dbedd370e09c69", size = 58344479, upload-time = "2026-08-11T16:25:15.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/8a/412fc273521b02cbfe0b5f8ad56cc696385f6eaeecdb9e9ae6a90111d98d/llvmlite-0.49.0-cp314-cp314-win_amd64.whl", hash = "sha256:294e2f0b70aef8f92d0ae7b203e2609f08beb39437eee73de59a21669331aae9", size = 42986588, upload-time = "2026-08-11T16:25:22.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/15/f47cf45c00c8b165ac3d268502dcb21d900e86f27fd338268a66ce922ab0/llvmlite-0.49.0-cp314-cp314-win_arm64.whl", hash = "sha256:95d1071023ed858b79f6971954fd7cc1f5dbcbab987718a4ccbe1411e47d0b81", size = 37441881, upload-time = "2026-08-11T16:25:28.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/2e/eafd488766d1c02413cba24f7b22acb9b3ccdfd8407e98d30eb16bac4e2a/llvmlite-0.49.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:f3f2ff0aeb17d34fcce9f79b99baac441cfd3efa41b83e233ca4530a72381f72", size = 40479230, upload-time = "2026-08-11T16:25:35.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/07/a2c4f04e2111ccc274b4d5e3331398a9dcf6d6e5e55d6444b1ad9d6381cf/llvmlite-0.49.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5555ea1d63928481cbf7fcb1d67452b216c7e5b393a4eb7aa1401e67f2a4fc4", size = 59890658, upload-time = "2026-08-11T16:25:43.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/f9/7b7b50f80b4585bcd78675ff3110c256877b11df32a8cde284f851762f57/llvmlite-0.49.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32adb84fdaae28aeb86fdb6253084ee707ee157289a2e98fe3caf48a62bee82", size = 58344482, upload-time = "2026-08-11T16:25:51.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/c6/32d68bfbf1d0c36888530ef6fd72864861af23dc546302b41033471a8c3a/llvmlite-0.49.0-cp314-cp314t-win_amd64.whl", hash = "sha256:be637e465010bc9c50f070468f7f1cf5385e92fee364d192dd5e6cea790ecba9", size = 42986602, upload-time = "2026-08-11T16:25:57.69Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logistro"
|
||||
version = "2.0.1"
|
||||
@@ -1045,6 +1074,131 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numba"
|
||||
version = "0.67.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "llvmlite", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/90/2544f4e3a61e501d6c9a5418fd4b905323222693d54a02cab0106a0af865/numba-0.67.0.tar.gz", hash = "sha256:cd75aa535b33fa05d9d930b1ae8af9f97a2881e96d72dfb38ec9b78284d9f851", size = 2836515, upload-time = "2026-08-11T23:04:00.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ed/55ba4e54ee878396de6b18e6533cc4a92fa519e8c82d55cf40f98c0a6831/numba-0.67.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:3fa3d1b27f96f2c0d54513d953d7197886aa1eaa7d2439a0eedc44d993fb181a", size = 2744821, upload-time = "2026-08-11T23:03:17.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/78/3f3c45dbaec3cf02bbb1825731beca50f591227e95143d6bd7a64897641c/numba-0.67.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c80c847301dc33dc8f84a97a952004023d9a05578ae4512b087176264cc1960", size = 3827182, upload-time = "2026-08-11T23:03:19.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/24/4e70cb86534283d859c3aea2302da523e41539b98dd6c3c4d0a42af95cda/numba-0.67.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7a7b0121466f1e9a8a074b0545fe90e16389623abf979b5d7c299dca1294d7e", size = 3532817, upload-time = "2026-08-11T23:03:22.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/4d/23dab7f4233be0fc34f54a169ed85238467cd24d8adf2498e5c12ea19dc7/numba-0.67.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfba1ac34f0363fb1a250a10e97240780d11e05227892f7286b26fbfd0ad58ce", size = 2815700, upload-time = "2026-08-11T23:03:23.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/58/915cddba90010348ed0444451132fdde9b000bcbaff1582029b5bf115d11/numba-0.67.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6004d8d5f28d4028687fb2d972d629295b13685943bd2ed5cd8810c3b848e219", size = 2745050, upload-time = "2026-08-11T23:03:25.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/38/926757caaac18a66f057d7544a63620bf360a07d281c9f7ecadd2aa83963/numba-0.67.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f63d43db06b4756424d6d2484737c902e0ae944a0eec3e8b0b4de2c695b15caa", size = 3884596, upload-time = "2026-08-11T23:03:27.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/6d/58291dc58da39d98b32db7f044729f6d8d4920cd9622fbab3179b54ff4c4/numba-0.67.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76d3335aaeffb9dc88309420890e73497a00be08a7530441bc2b58ffe025bfa5", size = 3585290, upload-time = "2026-08-11T23:03:29.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/63/ab21828b4056afed71f9ecb40f4de26c2c19de731cc001961aca74b79464/numba-0.67.0-cp312-cp312-win_amd64.whl", hash = "sha256:50e2b72406c18cda5dd7431b0082cb85ea94e06c64c33607248fc8bef92cfb81", size = 2815645, upload-time = "2026-08-11T23:03:31.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/dd/bd9fe772f6c84597b76cac229b3f2890f01a2c64fd70e48ceaae10dd65cb/numba-0.67.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:77e1c7173fee57a0d84e006c7e70346689d6cb3e7db503489bae58646b4eff7b", size = 2744872, upload-time = "2026-08-11T23:03:33.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/1c/c05609739cc41116d36e30cb2b41fb00f126bb52e1b0bac907051ad8a35d/numba-0.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c4953387c77864b596d8296e2cfbdef82b0eea4166ab4864b05d226c51143e0", size = 3892004, upload-time = "2026-08-11T23:03:35.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/77/a5276ad4178250403e0e2251f3e1f8ac18feac779b0474a8bcb08558490d/numba-0.67.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88f6e0f5cb6c545e158b6ef0496c01b6d6958a7ccc6634a1576a94bbbab29ff2", size = 3591878, upload-time = "2026-08-11T23:03:37.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/80/d48f0ba7442516ceb5a1585f0c81d3aa531bc96bfcabcd9f8f925768c426/numba-0.67.0-cp313-cp313-win_amd64.whl", hash = "sha256:b68ad5125fe245339cc8dcc036081fc1ea482c5063387b9612a76ccd83dc91cd", size = 2815504, upload-time = "2026-08-11T23:03:39.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/16/345b1e4774a08247aafcfdb93d4e8d24a3646366cbe72de33053fc0de1b5/numba-0.67.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f99f880ff25f418a67f9a1d00d0ddfbc63430f627b523e515085a592a7567f4b", size = 2745088, upload-time = "2026-08-11T23:03:41.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/36/e614ba2bc0f005ed0f37a6413f08fe705210297ddb9a37a475a8b9fdab61/numba-0.67.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269245a675abdd3e2c35ec6bb2f250355effa9032514d8f2354f0d2d10854bd", size = 3861040, upload-time = "2026-08-11T23:03:43.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/90/30c42a1dbc4176cf355e8e8be61803732c55597b1332925fe233912a43d9/numba-0.67.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f074a8e23db78490f11a3930c940be758316c10ac5985be83d2f298dc080acf7", size = 3561811, upload-time = "2026-08-11T23:03:46.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/6d/21bd16f770476e394c5e5f504935817967442a71251d6b86c244a2767980/numba-0.67.0-cp314-cp314-win_amd64.whl", hash = "sha256:4d576e62bf2c9370f61312b51573c4bb1f3fe96798bbab56730847a368a316c4", size = 2817421, upload-time = "2026-08-11T23:03:47.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/06/bb41b0e59b9ff52c94a2f01db24f6437df058caebb377b5f372fc343a6a2/numba-0.67.0-cp314-cp314-win_arm64.whl", hash = "sha256:7930748ce8355d2a5a28602abab056a61fdc676d17377f27d17993905428171f", size = 2788885, upload-time = "2026-08-11T23:03:49.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/7c/aa07151fbd0f4283f78de437cc196f9084789be89a2b4de3fdc2f6a4b414/numba-0.67.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:4a2ed006635bbd0fe45681ed49f3b4f4bad1abf0c233bcc5842c9e3a34cabd61", size = 2748150, upload-time = "2026-08-11T23:03:51.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/62/b8174ca95a4cc1a7ba1520767734e016991545590b8fbde521b681701a9f/numba-0.67.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa5f002f665bec321b950dacaa26ee009e1d720f6ac9d9856eed5efe1caa03a6", size = 3896986, upload-time = "2026-08-11T23:03:53.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/f9/3a7b6dbf81e01a48958b45ad2239edbc64707522ab17f11f9f18c44bf6d1/numba-0.67.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83ab968b0e0fa744eba03351282dd8000796e6ec8e4518f47bd3ed86c0a20c7b", size = 3614644, upload-time = "2026-08-11T23:03:55.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/5b/248f5681c121ca853a9f4e39d342a3e01b8a0261b0275853eb3d0d56aa20/numba-0.67.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00c964a5b94d3ae82d83ac162cd610755875b98dadb779fdde06e6bfcdbca47e", size = 2822870, upload-time = "2026-08-11T23:03:58.097Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opencv-python"
|
||||
version = "5.0.0.93"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", size = 48322443, upload-time = "2026-07-02T05:50:25.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", size = 34782755, upload-time = "2026-07-02T05:51:30.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/8a/b04776ec45d2dea08a1b176f1829201db3515d4ed16c35f8fcc9fa7beb16/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac", size = 50614064, upload-time = "2026-07-02T06:53:22.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", size = 71064711, upload-time = "2026-07-02T06:54:13.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/da/962579f1e703cbf8c5422fd1f576467dcb3b5b0b0b81c1471c979764353a/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2", size = 49798576, upload-time = "2026-07-02T06:54:33.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039", size = 73783032, upload-time = "2026-07-02T06:55:03.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4b/edaf83b996ca5a1a3d8ccad485706b9c6d4742b13b9c4586bf1c1e7d9423/opencv_python-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157", size = 35564734, upload-time = "2026-07-02T05:49:57.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.8"
|
||||
@@ -1592,6 +1746,57 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scipy"
|
||||
version = "1.18.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simplejson"
|
||||
version = "4.1.1"
|
||||
@@ -1769,6 +1974,24 @@ server = [
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "weasyprint" },
|
||||
]
|
||||
vision = [
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "vs-pm2d", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
vision-worker = [
|
||||
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "pillow", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "python-multipart", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "uvicorn", extra = ["standard"], marker = "python_full_version >= '3.13'" },
|
||||
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "vs-pm2d", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
@@ -1777,6 +2000,7 @@ requires-dist = [
|
||||
{ name = "asyncmy", marker = "extra == 'server'", specifier = ">=0.2.0" },
|
||||
{ name = "bcrypt", marker = "extra == 'server'", specifier = ">=4.0.0" },
|
||||
{ name = "coverage", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||
{ name = "fastapi", marker = "python_full_version >= '3.13' and extra == 'vision-worker'", specifier = ">=0.110.0" },
|
||||
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.110.0" },
|
||||
{ name = "flask", marker = "extra == 'client'", specifier = ">=3.0.0" },
|
||||
{ name = "flask-babel", marker = "extra == 'client'", specifier = ">=4.0.0" },
|
||||
@@ -1786,7 +2010,9 @@ requires-dist = [
|
||||
{ name = "httpx", marker = "extra == 'server'", specifier = ">=0.27.0" },
|
||||
{ name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.0" },
|
||||
{ name = "kaleido", marker = "extra == 'server'", specifier = ">=0.2.0" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13' and extra == 'vision'" },
|
||||
{ name = "pdfplumber", marker = "extra == 'server'", specifier = ">=0.10.0" },
|
||||
{ name = "pillow", marker = "python_full_version >= '3.13' and extra == 'vision-worker'", specifier = ">=10.0.0" },
|
||||
{ name = "pillow", marker = "extra == 'server'", specifier = ">=10.0.0" },
|
||||
{ name = "plotly", marker = "extra == 'server'", specifier = ">=5.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
@@ -1794,14 +2020,21 @@ requires-dist = [
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "python-multipart", marker = "python_full_version >= '3.13' and extra == 'vision-worker'", specifier = ">=0.0.6" },
|
||||
{ name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.6" },
|
||||
{ name = "requests", marker = "extra == 'client'", specifier = ">=2.31.0" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'server'", specifier = ">=2.0.0" },
|
||||
{ name = "tiemeasureflow", extras = ["vision"], marker = "extra == 'vision-worker'" },
|
||||
{ name = "urllib3", marker = "extra == 'client'", specifier = ">=2.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "python_full_version >= '3.13' and extra == 'vision-worker'", specifier = ">=0.30.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.30.0" },
|
||||
{ name = "visionsuite", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-core" },
|
||||
{ name = "vs-measure", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-measure" },
|
||||
{ name = "vs-pm2d", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-pm2d" },
|
||||
{ name = "vs-task", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-task" },
|
||||
{ name = "weasyprint", marker = "extra == 'server'", specifier = ">=62.0" },
|
||||
]
|
||||
provides-extras = ["server", "client", "dev"]
|
||||
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
|
||||
|
||||
[[package]]
|
||||
name = "tinycss2"
|
||||
@@ -1919,6 +2152,94 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "visionsuite"
|
||||
version = "0.0.1"
|
||||
source = { editable = "vendor/visionsuite/packages/vs-core" }
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "ezdxf", marker = "extra == 'dxf'", specifier = ">=1.0" },
|
||||
{ name = "matplotlib", marker = "extra == 'dev'" },
|
||||
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
|
||||
{ name = "numpy", specifier = ">=1.24" },
|
||||
{ name = "opencv-python", specifier = ">=4.8" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
||||
]
|
||||
provides-extras = ["dev", "fast", "dxf"]
|
||||
|
||||
[[package]]
|
||||
name = "vs-measure"
|
||||
version = "0.1.0"
|
||||
source = { editable = "vendor/visionsuite/packages/vs-measure" }
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "scipy", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "numpy", specifier = ">=1.24" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
||||
{ name = "scipy", specifier = ">=1.11" },
|
||||
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "vs-pm2d"
|
||||
version = "0.1.0"
|
||||
source = { editable = "vendor/visionsuite/packages/vs-pm2d" }
|
||||
dependencies = [
|
||||
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "numba", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "pillow", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "python-multipart", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "uvicorn", extra = ["standard"], marker = "python_full_version >= '3.13'" },
|
||||
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "numba", specifier = ">=0.65.0" },
|
||||
{ name = "numpy", specifier = ">=1.24" },
|
||||
{ name = "opencv-python", specifier = ">=4.8" },
|
||||
{ name = "pillow", specifier = ">=12.2.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.26" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34" },
|
||||
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "httpx", specifier = ">=0.28.1" }]
|
||||
|
||||
[[package]]
|
||||
name = "vs-task"
|
||||
version = "0.1.0"
|
||||
source = { editable = "vendor/visionsuite/packages/vs-task" }
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "numpy", specifier = ">=1.24" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
||||
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
|
||||
{ name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
|
||||
+1
Submodule vendor/visionsuite added at f095fcc254
Reference in New Issue
Block a user