Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae2353f7f8 | |||
| d652891c1c | |||
| 0b22643d69 | |||
| 15e31e2b8a | |||
| f8f5f248ff | |||
| f33415df6a | |||
| 62b5e412ca | |||
| 0e92959022 | |||
| f23e87e585 | |||
| 114e4a10ae | |||
| 3aac8cc490 | |||
| 8b3e897668 | |||
| eea3e319fb | |||
| bfc7c391e8 | |||
| 8d4c4020f6 | |||
| 0a68a9995e | |||
| 959e92552b | |||
| 8431aa7095 | |||
| 69cbf53060 | |||
| 23012fdfbb | |||
| d89a2e6c8d | |||
| 242add047b | |||
| 09c4029978 | |||
| cd40b81f1e | |||
| 6e60e0b1a9 | |||
| 6bc6c0c0d5 | |||
| e5989c9d23 | |||
| 0af6692786 | |||
| 36074ab2f7 | |||
| fd1bbce8aa | |||
| 1453b31cee | |||
| 192d4b590c | |||
| b9f43a0ede | |||
| 5bf374aadd | |||
| 74fe5665b5 | |||
| be904bac07 | |||
| 011c08c9d8 | |||
| 82ad88538d | |||
| bafc776ef7 | |||
| 8fcb4796d5 | |||
| 8d7fa9a12a | |||
| c911449b7c | |||
| ac8604eff5 | |||
| 631ba4ee05 | |||
| 40eed5c2af | |||
| 275bbd1a5f | |||
| 95d643d6d8 | |||
| 9306fbf653 | |||
| 1a5e98f072 | |||
| fdfcf05951 | |||
| 26884ebf4f | |||
| 8b3d3a8424 | |||
| 153fafd1b4 | |||
| cf8fab4c08 | |||
| b7cb061fee | |||
| 6a8ad4dae4 | |||
| d750cf9e0e | |||
| 2564498915 | |||
| 21f8bae5ac | |||
| c1f8a5e2ff | |||
| dee27ac8e1 | |||
| 736b853e22 | |||
| 627ae15591 | |||
| c6d4260ede | |||
| df1727be73 | |||
| 459d1c71ea | |||
| 05e3faa9d4 | |||
| dc94b2e778 | |||
| 3c55550d93 | |||
| e07c4d4eee | |||
| 0b45f1c35a | |||
| 743f246cbf | |||
| f541f4a810 | |||
| dffb11f9f2 | |||
| c8dd5eecfd | |||
| f7d6d008c7 | |||
| 837f3d1069 | |||
| f5a2affa0f | |||
| 7ef28b7a9f | |||
| 06b87a15b3 | |||
| 3b3ecceb2a | |||
| 3899d5ecd9 | |||
| f986c172c2 | |||
| e44ac3129f | |||
| 4e52095423 | |||
| 23bfc93f1c | |||
| 07e23b957d | |||
| c1c7ae8d0e | |||
| cf49695140 | |||
| c9e01a223b | |||
| 3690c35c47 | |||
| fc1902a1bb | |||
| 6648882ec1 | |||
| b5aa19170e | |||
| 2d03577165 | |||
| 27dc5e77af | |||
| 8e3316957c | |||
| 3c1d5f1323 | |||
| db5d686938 | |||
| c764cf73c6 | |||
| 86290274e5 | |||
| ee0f65ed39 | |||
| 18d0db8a0c | |||
| 3856ee335a | |||
| accf416cc9 | |||
| 84a4b5f34d | |||
| 9238f8926b |
@@ -92,6 +92,8 @@ SESSION_COOKIE_SECURE=true
|
||||
VISION_RUN_TIMEOUT_S=100
|
||||
# Quante esecuzioni di visione in parallelo accetta il worker.
|
||||
VISION_MAX_CONCURRENCY=2
|
||||
# #46: radice sotto cui il worker legge le cartelle ricetta (recipe_dir relativo).
|
||||
VISION_RECIPES_ROOT=/app/uploads
|
||||
|
||||
# --- Docker ---
|
||||
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# TieMeasureFlow#81: static del frontend sempre con LF, anche con core.autocrlf=true su Windows:
|
||||
# le impronte sha256 di static/vendor/VERSIONS.md (test_offline.py) e il pacchetto Windows le vogliono identiche.
|
||||
src/frontend/flask_app/static/** text=auto eol=lf
|
||||
@@ -0,0 +1,119 @@
|
||||
# TieMeasureFlow#76: setup.exe di TieMeasureFlow per Windows, generato al tag v*.
|
||||
# Cosa fa: submodule vendor/visionsuite (deploy key), build.ps1 (uv sync Python 3.13, Tailwind,
|
||||
# Nuitka --standalone, migrazioni .pyc, DLL di WeasyPrint, Inno Setup), poi il setup come
|
||||
# artifact e come asset della release Gitea del tag.
|
||||
# Da cosa dipende: un runner Gitea con label `windows` che giri su un host Windows (non in
|
||||
# container) con Visual Studio/Build Tools (MSVC), git e node (serve a actions/checkout e a
|
||||
# Tailwind); uv e Inno Setup li installa il workflow se mancano. Secret SUBMODULI_SSH_KEY
|
||||
# (deploy key di sola lettura di TieVision-Core, impostato sul repo il 17/10/2026).
|
||||
# Il runner `windows` e' act_runner in modalita' host su daitarn (capacity 1).
|
||||
# ⚠️ upload-artifact@v3: e' la versione che usa gia' vs-native.yml in TieVision-Core su questo
|
||||
# Gitea; la v4 usa un protocollo che act_runner puo' non supportare.
|
||||
# ⚠️ L'asset della release usa il token del job (secrets.GITHUB_TOKEN): se la chiamata risponde
|
||||
# 403 serve un token personale in un secret (es. RILASCI_TOKEN) al posto di GITHUB_TOKEN.
|
||||
# ⚠️ La build da freddo e' lunga (Nuitka compila backend, visione, plotly, opencv...): timeout 180 min.
|
||||
name: rilascio-windows
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
setup-windows:
|
||||
runs-on: windows
|
||||
timeout-minutes: 180
|
||||
defaults:
|
||||
run:
|
||||
shell: powershell
|
||||
steps:
|
||||
# Checkout senza ssh-key (come ci.yml): i submodule si inizializzano a parte, con la deploy key.
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Submodule privati (deploy key in sola lettura)
|
||||
env:
|
||||
SUBMODULI_SSH_KEY: ${{ secrets.SUBMODULI_SSH_KEY }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# cartella temporanea del job, non ~/.ssh: su un runner host e' l'account dell'utente
|
||||
$ssh = Join-Path $env:TEMP ("submoduli-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Force $ssh | Out-Null
|
||||
$chiave = Join-Path $ssh 'chiave'
|
||||
# LF e newline finale: OpenSSH rifiuta una chiave con CRLF
|
||||
[System.IO.File]::WriteAllText($chiave, ($env:SUBMODULI_SSH_KEY.Trim() + "`n"), (New-Object System.Text.UTF8Encoding $false))
|
||||
# OpenSSH su Windows rifiuta una chiave leggibile da altri utenti; (F) e non (R):
|
||||
# con la sola lettura Remove-Item finale non puo' cancellarla
|
||||
icacls $chiave /inheritance:r | Out-Null
|
||||
icacls $chiave /grant:r "$($env:USERNAME):(F)" | Out-Null
|
||||
$known = Join-Path $ssh 'known_hosts'
|
||||
[System.IO.File]::WriteAllText($known, "[git.tielogic.xyz]:222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDvABW6Tvruqkf7KJhJZUiAX6NeCx0enwcNXzZbECQ8J`n", (New-Object System.Text.UTF8Encoding $false))
|
||||
$env:GIT_SSH_COMMAND = "ssh -i $($chiave -replace '\\','/') -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$($known -replace '\\','/')"
|
||||
try {
|
||||
git submodule update --init --recursive
|
||||
if ($LASTEXITCODE -ne 0) { throw "git submodule update fallito" }
|
||||
} finally {
|
||||
# la deploy key non resta mai sul disco del runner
|
||||
Remove-Item $ssh -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
- name: uv (se manca)
|
||||
run: |
|
||||
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||
irm https://astral.sh/uv/install.ps1 | iex
|
||||
}
|
||||
# il PATH del passo successivo
|
||||
"$env:USERPROFILE\.local\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
|
||||
|
||||
- name: Node (se manca)
|
||||
run: |
|
||||
# serve a Tailwind (npx) in build.ps1
|
||||
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||
winget install --id OpenJS.NodeJS.LTS -e --silent --accept-package-agreements --accept-source-agreements
|
||||
}
|
||||
"$env:ProgramFiles\nodejs" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
|
||||
|
||||
- name: Inno Setup (se manca)
|
||||
run: |
|
||||
$trovato = (Get-Command ISCC.exe -ErrorAction SilentlyContinue) -or
|
||||
(Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe") -or
|
||||
(Test-Path "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")
|
||||
if (-not $trovato) {
|
||||
winget install --id JRSoftware.InnoSetup -e --silent --accept-package-agreements --accept-source-agreements
|
||||
}
|
||||
|
||||
- name: Build (Nuitka + Inno Setup)
|
||||
# GITHUB_REF_NAME e' il tag (v0.1.0); con workflow_dispatch e' il ramo: build.ps1 rifiuta
|
||||
# un nome senza numeri, quindi in quel caso si passa 0.0.0
|
||||
run: |
|
||||
$v = '${{ github.ref_name }}'
|
||||
if ($v -notmatch '^v?\d+\.\d+\.\d+') { $v = '0.0.0' }
|
||||
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione $v
|
||||
if ($LASTEXITCODE -ne 0) { throw "build.ps1 fallito" }
|
||||
|
||||
- name: Setup come artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: TieMeasureFlow-setup
|
||||
path: dist/TieMeasureFlow-setup-*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Asset della release Gitea (solo ai tag)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$h = @{ Authorization = "token $env:TOKEN" }
|
||||
# la release del tag: la crea il primo job che arriva, poi si riusa
|
||||
try {
|
||||
$rel = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG"
|
||||
} catch {
|
||||
$corpo = @{ tag_name = $env:TAG; name = "TieMeasureFlow $env:TAG" } | ConvertTo-Json
|
||||
$rel = Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/json' -Body $corpo "$env:API/releases"
|
||||
}
|
||||
$setup = Get-ChildItem dist\TieMeasureFlow-setup-*.exe | Select-Object -First 1
|
||||
# curl.exe (incluso in Windows 10+) fa il multipart senza codice a mano
|
||||
curl.exe --fail -sS -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@$($setup.FullName)" "$env:API/releases/$($rel.id)/assets?name=$($setup.Name)"
|
||||
if ($LASTEXITCODE -ne 0) { throw "caricamento dell'asset fallito" }
|
||||
@@ -73,3 +73,6 @@ docker-compose.override.yml
|
||||
|
||||
# Subagent-driven development scratch: ledger, briefs, review packages
|
||||
.superpowers/
|
||||
|
||||
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
|
||||
packaging/windows/_strumenti.py
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[submodule "vendor/visionsuite"]
|
||||
path = vendor/visionsuite
|
||||
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git
|
||||
url = ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git
|
||||
|
||||
@@ -287,7 +287,9 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
|
||||
|
||||
## Visione (V3.0.0)
|
||||
|
||||
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** su `ssh://git.tielogic.xyz:222`: un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
||||
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
||||
|
||||
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 2e73691.
|
||||
|
||||
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
|
||||
|
||||
@@ -333,3 +335,25 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
||||
- Font UI: Inter
|
||||
- Font numeri: JetBrains Mono
|
||||
- Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme`
|
||||
|
||||
## Pacchetto Windows (TieMeasureFlow#76)
|
||||
|
||||
- File: `packaging/windows/avvio.py` (entry point: licenza, `config.env`, variabili d'ambiente, migrazioni, quattro server in thread), `build.ps1` (uv sync 3.13, Tailwind, Nuitka `--standalone`, migrazioni come `.pyc`, Inno Setup), `TieMeasureFlow.iss`, `TieMeasureFlow.ico`; CI `.gitea/workflows/rilascio-windows.yml` (tag `v*`, runner con etichetta `windows`); test in `packaging/windows/tests/` (licenza firmata con la chiave di prova del contratto, server e migrazioni sostituiti). Piani collegati: SQLite su file (#77) e PDF/DLL (#78). Modello: TieVision-Studio#1.
|
||||
- Un solo processo `TieMeasureFlow.exe`: thread `web` (Flask + waitress, `--host`, 8780), `backend` (8000), `vision` (8100), `camera` (8765) con uvicorn. Dati in `%PROGRAMDATA%\Tielogic\TieMeasureFlow` (`tmf.db`, `uploads`, `camera`, `log`, `config.env`); licenza in `%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json` (mai nel repo).
|
||||
- ⚠️ Python del pacchetto: 3.13 in un ambiente separato (`build\venv313`, `UV_PROJECT_ENVIRONMENT`); il repo resta su `.python-version` 3.11 per lo sviluppo. `vs-license` e `vs-camera` hanno il marker `python_version>='3.13'` nel gruppo `windows`, altrimenti `uv lock` fallisce sul `requires-python >=3.11`.
|
||||
- ⚠️ Le variabili d'ambiente si impostano PRIMA di importare backend e frontend (leggono la configurazione all'import): in `avvio.py` gli import pesanti stanno dentro le funzioni. `APP_ENV=production` rifiuta i segreti di default, quindi `config.env` genera anche `DB_PASSWORD` (inutilizzata con SQLite).
|
||||
- ⚠️ Il frontend Flask non è un pacchetto (`from config import Config`, `blueprints`, `services`, `app`) e `scripts/camera_service.py` neppure: da sorgente `avvio.py` aggiunge `flask_app` e `scripts` al `sys.path`; per Nuitka `build.ps1` li mette su `PYTHONPATH` e li include con `--include-module/--include-package`. I dati di Flask (`templates`, `static`, `translations`) vanno nella radice della cartella standalone, `src\backend\templates` sotto `src\backend`.
|
||||
- ⚠️ Il browser parla con Flask (8780); la pagina di setup `/api/setup` invece è del backend: si apre su `http://127.0.0.1:8000/api/setup` (in Docker la serve nginx). `avvio.py` la apre da sola se il database non ha utenti.
|
||||
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
|
||||
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
|
||||
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
|
||||
- ⚠️ Strumenti di visione (TieMeasureFlow#82): nel binario non ci sono i metadati di `vs-tools`, quindi `load_tools()` (entry point `visionsuite.task.tools`) non trova nulla e ogni ricetta fallisce con tipo sconosciuto. `build.ps1` genera `packaging/windows/_strumenti.py` (`MODULI`, gitignorato, letto dal venv del build), `avvio.py` `registra_strumenti()` lo importa prima dei server, e prima di Inno Setup `build.ps1` lancia l'exe con `--controlla-strumenti` (senza licenza né server; JSON in `...\log\controllo-strumenti.json`, exit 3 se il catalogo è vuoto o un modulo non si importa).
|
||||
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
|
||||
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
|
||||
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
|
||||
- ⚠️ `SETUP_PASSWORD` e le chiavi stanno in `config.env`, in una cartella scrivibile da tutti gli utenti locali (serve all'exe, che gira con l'utente che lo lancia): non è un luogo per segreti ad alto valore.
|
||||
- ⚠️ CI di rilascio: il runner `windows` è su daitarn (act_runner, modalità host, capacity 1); `upload-artifact` è alla v3. Non committare `build/` e `dist/`.
|
||||
- ⚠️ `STATION_CODE`: senza una stazione l'interfaccia risponde 503 («Stazione non configurata»). `config.env` nasce con `STATION_CODE=ST-DEFAULT` (la stazione creata dal seed della pagina di setup); le chiavi extra di `config.env` (`STATION_CODE`, `STATION_SWITCH_ENABLED`, ...) passano all'ambiente ma non sovrascrivono le variabili fisse del contratto.
|
||||
- ⚠️ Alembic anche fuori da `avvio.py`: `setup.py` (`_stamp_head`) apre `alembic.ini` con un `Config` suo, quindi `build.ps1` scrive `sourceless = true` nell'`alembic.ini` del pacchetto; e `env.py` chiama `logging.config.fileConfig`, che azzererebbe il log su file, quindi `avvio.py` lo sostituisce con un no-op.
|
||||
- ⚠️ Bug noto, non del pacchetto: `POST /api/setup/init-db` risponde 500 (`asyncio.run() cannot be called from a running event loop`, perché `_stamp_head` esegue `env.py` dentro il loop). Nel pacchetto non serve: le migrazioni creano già le tabelle; la pagina di setup si usa per «Seed» o per creare l'amministratore.
|
||||
- ⚠️ Le richieste di login all'API sono limitate (5 al minuto): negli script di prova distanziarle.
|
||||
|
||||
@@ -47,6 +47,13 @@ WORKDIR /app/flask_app
|
||||
RUN uv run --project /app pybabel compile -d translations && \
|
||||
chown -R app:app /app /tmp/uv-cache
|
||||
|
||||
# Commit mostrato nel piede di pagina (#70). .git e' escluso dal contesto di
|
||||
# build (.dockerignore), quindi lo passa chi costruisce; facoltativo, senza il
|
||||
# piede mostra solo la versione. Uso:
|
||||
# APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client
|
||||
ARG APP_COMMIT=""
|
||||
ENV APP_COMMIT=${APP_COMMIT}
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
@@ -333,6 +333,7 @@ TieMeasureFlow/
|
||||
|---|---|
|
||||
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
||||
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
||||
| `APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client` | Ricostruisci il client mostrando nel piè di pagina il commit (oltre alla versione di `pyproject.toml`); senza `APP_COMMIT` compare solo la versione |
|
||||
| `docker compose logs -f server` | Segui log server in tempo reale |
|
||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||
| `docker compose ps` | Stato di tutti i servizi |
|
||||
@@ -515,6 +516,70 @@ Copia `.env.example` in `.env` e configura:
|
||||
|
||||
---
|
||||
|
||||
## Camera di stazione (demo fiera, #72)
|
||||
|
||||
Per acquisire il fotogramma da una camera locale (per esempio quella del TC bench) senza passare dall'upload, sul PC della stazione gira un piccolo servizio nativo, fuori da Docker, perché la camera GigE non si raggiunge dal container. Si avvia dalla radice del repository con l'ambiente di VisionSuite (`uv sync --all-packages` in `vendor/visionsuite`):
|
||||
|
||||
```bash
|
||||
vendor/visionsuite/.venv/bin/python scripts/camera_service.py [--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
|
||||
```
|
||||
|
||||
Con `--folder` la sorgente `camera:folder` restituisce a rotazione le immagini della cartella, utile per provare senza hardware. Su Linux, perché il container `client` lo raggiunga, si usa `--host 172.17.0.1` (gateway di docker0; il compose di sviluppo mappa `host.docker.internal` su quell'host). Su Windows con Docker Desktop si presume che `host.docker.internal` raggiunga il loopback dell'host, ma va verificato sul PC della demo.
|
||||
|
||||
⚠️ Il servizio non ha autenticazione: un `--host` diverso dal loopback va usato solo su una rete isolata.
|
||||
|
||||
Nella schermata Stazioni si compilano `camera_url` (per esempio `http://host.docker.internal:8765`) e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Per avere due postazioni sullo stesso PC si abilita `STATION_SWITCH_ENABLED`.
|
||||
|
||||
---
|
||||
|
||||
## Pacchetto Windows
|
||||
|
||||
Per i PC senza Docker (per esempio il PC della stazione in fiera) TieMeasureFlow si distribuisce come programma di installazione per Windows, `TieMeasureFlow-setup-<versione>.exe`. Funziona completamente offline, non richiede Docker né MySQL, e non contiene codice sorgente Python: il codice del prodotto è compilato con Nuitka. Il programma parte solo con una licenza valida, legata al computer.
|
||||
|
||||
### Installazione
|
||||
|
||||
1. Eseguire `TieMeasureFlow-setup-<versione>.exe` (serve un account amministratore; per una prova senza amministratore si può lanciare il setup con `/CURRENTUSER`). Per un'installazione silenziosa: `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART`.
|
||||
2. Dal menu Start aprire «Impronta di questo computer» e comunicare l'impronta a Tielogic. L'impronta viene salvata anche in `C:\ProgramData\Tielogic\licenze\impronta-tiemeasureflow.txt`.
|
||||
3. Copiare il file di licenza ricevuto in `C:\ProgramData\Tielogic\licenze\tiemeasureflow.json`. La licenza non è inclusa nel setup e non va mai inserita nel repository.
|
||||
4. Avviare «TieMeasureFlow» dal menu Start o dal desktop. Senza una licenza valida (mancante, scaduta, di un altro computer o di un altro prodotto) il programma mostra il motivo e l'impronta ed esce.
|
||||
|
||||
### Primo avvio
|
||||
|
||||
Il programma gira in un solo processo, senza finestra, e apre il browser. Ascolta solo sul computer locale (`127.0.0.1`); per raggiungere l'interfaccia da altri dispositivi della rete si avvia l'exe con `--host 0.0.0.0` (per esempio da un collegamento personalizzato).
|
||||
|
||||
| Componente | Indirizzo |
|
||||
|---|---|
|
||||
| Interfaccia web (Flask) | http://127.0.0.1:8780/ |
|
||||
| Backend API (FastAPI) | http://127.0.0.1:8000/ |
|
||||
| Worker di visione | http://127.0.0.1:8100/ |
|
||||
| Camera della stazione | http://127.0.0.1:8765/ (`/camera/sources`) |
|
||||
|
||||
Al primo avvio vengono create la cartella dati `C:\ProgramData\Tielogic\TieMeasureFlow` e il file `config.env` con le chiavi segrete generate (`SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY`, `SETUP_PASSWORD`, `DB_PASSWORD`); al riavvio vengono rilette, quindi le sessioni e le password restano valide. Il database è un file SQLite (`tmf.db`); le migrazioni si applicano da sole a ogni avvio.
|
||||
|
||||
Se il database non contiene ancora utenti, il browser si apre direttamente sulla pagina di setup `http://127.0.0.1:8000/api/setup`. La pagina chiede la `SETUP_PASSWORD`, che si legge in `config.env`, e permette di inizializzare il database e creare il primo amministratore (vedi «Setup Iniziale»). Terminato il setup si accede all'interfaccia su `http://127.0.0.1:8780/`.
|
||||
|
||||
Per le camere della stazione si compila nella schermata Stazioni `camera_url` = `http://127.0.0.1:8765` e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Con `camera:folder` il servizio restituisce a rotazione le immagini che si copiano in `C:\ProgramData\Tielogic\TieMeasureFlow\camera`. Per le camere GigE/GenTL il driver (producer `.cti`) è quello installato dal produttore della camera.
|
||||
|
||||
Altre informazioni utili:
|
||||
|
||||
- I log sono in `C:\ProgramData\Tielogic\TieMeasureFlow\log\tiemeasureflow.log`.
|
||||
- Una seconda esecuzione mentre il programma è già attivo apre soltanto il browser.
|
||||
- Per fermare il programma si usa «Arresta TieMeasureFlow» nel menu Start (chiude il processo).
|
||||
- La disinstallazione non cancella licenze, database, file caricati e `config.env`: sono dati del cliente.
|
||||
- I report PDF usano le librerie grafiche incluse nel pacchetto e un browser Chrome o Edge già installato sul computer per i grafici.
|
||||
|
||||
### Come si costruisce
|
||||
|
||||
Il setup lo produce la CI Gitea (`.gitea/workflows/rilascio-windows.yml`) quando si crea un tag `v*`, su un runner con etichetta `windows`; il file compare come allegato della release. In locale, dalla radice del repository con il sottomodulo `vendor/visionsuite` inizializzato:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione 3.0.0
|
||||
```
|
||||
|
||||
Servono `uv`, Node.js, Visual Studio con il carico C++ e Inno Setup 6. Il risultato è `dist\TieMeasureFlow-setup-3.0.0.exe`. La prima compilazione richiede decine di minuti. I test dell'avvio si lanciano con l'ambiente Python 3.13 del pacchetto: `uv run --python 3.13 --extra server --extra client --extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q`.
|
||||
|
||||
---
|
||||
|
||||
## Documentazione
|
||||
|
||||
Indice completo: [`docs/README.md`](docs/README.md).
|
||||
|
||||
@@ -75,6 +75,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
args:
|
||||
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||
APP_COMMIT: ${APP_COMMIT:-}
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@@ -85,6 +88,9 @@ services:
|
||||
# on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure,
|
||||
# altrimenti il browser non lo rimanda mai indietro.
|
||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
||||
# #72: da container si raggiunge il servizio camera (vs-gui) sull'host Linux
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -71,6 +71,9 @@ services:
|
||||
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||
container_name: tmflow-vision
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# #46: radice delle cartelle ricetta (volume uploads, sola lettura)
|
||||
VISION_RECIPES_ROOT: /app/uploads
|
||||
volumes:
|
||||
- upload_data:/app/uploads:ro
|
||||
healthcheck:
|
||||
@@ -86,6 +89,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
args:
|
||||
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||
APP_COMMIT: ${APP_COMMIT:-}
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,81 @@
|
||||
; Installer di TieMeasureFlow (TieMeasureFlow#76).
|
||||
; Cosa fa: copia la cartella standalone di Nuitka in Program Files\Tielogic\TieMeasureFlow,
|
||||
; crea in ProgramData le cartelle scrivibili da tutti gli utenti (licenze, uploads, camera, log)
|
||||
; e i collegamenti in menu Start e sul desktop.
|
||||
; Come si usa: lo lancia build.ps1 con /DVersione /DVersione4 /DOrigine /DUscita; a mano:
|
||||
; ISCC /DVersione=3.0.0 /DVersione4=3.0.0.0 /DOrigine=build\nuitka\avvio.dist /DUscita=dist TieMeasureFlow.iss
|
||||
; Setup silenzioso: TieMeasureFlow-setup-X.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||
; (/CURRENTUSER per installare senza amministratore, in %LOCALAPPDATA%\Programs).
|
||||
; ATTENZIONE: la licenza NON e' nel setup: va copiata in {commonappdata}\Tielogic\licenze\tiemeasureflow.json
|
||||
; (la cartella e' scrivibile dagli utenti). La disinstallazione lascia licenze, database (tmf.db),
|
||||
; uploads e config.env: sono dati del cliente.
|
||||
|
||||
#ifndef Versione
|
||||
#define Versione "0.0.0"
|
||||
#endif
|
||||
#ifndef Versione4
|
||||
#define Versione4 "0.0.0.0"
|
||||
#endif
|
||||
#ifndef Origine
|
||||
#define Origine "..\..\build\nuitka\avvio.dist"
|
||||
#endif
|
||||
#ifndef Uscita
|
||||
#define Uscita "..\..\dist"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
; AppId proprio di TieMeasureFlow (diverso da quello di TieVision Studio)
|
||||
AppId={{B7D4E2A1-5C93-4F6E-8A0B-3D1F7E9C2A64}
|
||||
AppName=TieMeasureFlow
|
||||
AppVersion={#Versione}
|
||||
AppVerName=TieMeasureFlow {#Versione}
|
||||
AppPublisher=Tielogic
|
||||
DefaultDirName={autopf}\Tielogic\TieMeasureFlow
|
||||
DefaultGroupName=Tielogic\TieMeasureFlow
|
||||
PrivilegesRequired=admin
|
||||
; /CURRENTUSER solo da riga di comando: serve alle prove senza amministratore
|
||||
PrivilegesRequiredOverridesAllowed=commandline
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
OutputDir={#Uscita}
|
||||
OutputBaseFilename=TieMeasureFlow-setup-{#Versione}
|
||||
SetupIconFile=TieMeasureFlow.ico
|
||||
UninstallDisplayIcon={app}\TieMeasureFlow.exe
|
||||
VersionInfoVersion={#Versione4}
|
||||
VersionInfoCompany=Tielogic
|
||||
VersionInfoProductName=TieMeasureFlow
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
DisableProgramGroupPage=yes
|
||||
; il programma in esecuzione tiene aperti i file: Inno chiede di chiuderlo (in silenzioso lo chiude)
|
||||
CloseApplications=yes
|
||||
|
||||
[Languages]
|
||||
Name: "italiano"; MessagesFile: "compiler:Languages\Italian.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Crea un collegamento sul desktop"; GroupDescription: "Collegamenti:"
|
||||
|
||||
[Dirs]
|
||||
; scrivibili da tutti gli utenti: l'exe gira con l'utente che lo lancia, non con admin
|
||||
Name: "{commonappdata}\Tielogic\licenze"; Permissions: users-modify
|
||||
Name: "{commonappdata}\Tielogic\TieMeasureFlow"; Permissions: users-modify
|
||||
Name: "{commonappdata}\Tielogic\TieMeasureFlow\uploads"; Permissions: users-modify
|
||||
Name: "{commonappdata}\Tielogic\TieMeasureFlow\camera"; Permissions: users-modify
|
||||
Name: "{commonappdata}\Tielogic\TieMeasureFlow\log"; Permissions: users-modify
|
||||
|
||||
[Files]
|
||||
Source: "{#Origine}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"
|
||||
Name: "{group}\Impronta di questo computer"; Filename: "{app}\TieMeasureFlow.exe"; Parameters: "--impronta"
|
||||
; l'exe non ha finestra: per fermarlo si chiude il processo
|
||||
Name: "{group}\Arresta TieMeasureFlow"; Filename: "{sys}\taskkill.exe"; Parameters: "/IM TieMeasureFlow.exe /F"; Flags: runminimized
|
||||
Name: "{group}\Cartella dati e configurazione"; Filename: "{commonappdata}\Tielogic\TieMeasureFlow"
|
||||
Name: "{group}\Disinstalla TieMeasureFlow"; Filename: "{uninstallexe}"
|
||||
Name: "{autodesktop}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\TieMeasureFlow.exe"; Description: "Avvia TieMeasureFlow"; Flags: nowait postinstall skipifsilent
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Entry point del pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
|
||||
|
||||
Cosa fa: controlla la licenza (prodotto "tiemeasureflow"); se non e' valida mostra il
|
||||
motivo e l'impronta del computer, scrive l'impronta in
|
||||
%PROGRAMDATA%\\Tielogic\\licenze\\impronta-tiemeasureflow.txt ed esce con 1.
|
||||
Se e' valida: prepara la cartella dati (%PROGRAMDATA%\\Tielogic\\TieMeasureFlow, con
|
||||
config.env dei segreti generati al primo avvio), imposta le variabili d'ambiente del
|
||||
contratto, esegue le migrazioni Alembic (SQLite su file) e avvia in un solo processo
|
||||
quattro server, ciascuno nel suo thread:
|
||||
web Flask (src/frontend/flask_app) servito da waitress, --host (default 127.0.0.1) :8780
|
||||
backend FastAPI src.backend.main:app, 127.0.0.1:8000
|
||||
vision FastAPI src.vision_worker.main:app, 127.0.0.1:8100
|
||||
camera scripts/camera_service.py (camere della stazione), 127.0.0.1:8765
|
||||
Poi apre il browser. Se un'altra copia e' gia' in esecuzione apre solo il browser.
|
||||
|
||||
Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radice del repo
|
||||
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
|
||||
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
|
||||
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
|
||||
`--controlla-strumenti` (TieMeasureFlow#82): NON richiede la licenza, non avvia server, migrazioni
|
||||
ne' database; registra gli strumenti, scrive su stdout e in
|
||||
%PROGRAMDATA%\\Tielogic\\TieMeasureFlow\\log\\controllo-strumenti.json un JSON
|
||||
{strumenti, kind, falliti, unavailable} ed esce con 0 (strumenti > 0 e nessun fallito) o 3.
|
||||
La usa build.ps1 prima di Inno Setup.
|
||||
|
||||
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
|
||||
|
||||
⚠️ L'exe e' senza console: stdout/stderr possono essere None, quindi il log va su file
|
||||
(<dati>\\log\\tiemeasureflow.log) e uvicorn gira con log_config=None.
|
||||
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
|
||||
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
|
||||
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
|
||||
⚠️ Nel binario Nuitka mancano i metadati di vs-tools, quindi `load_tools()` (che cerca gli
|
||||
strumenti come entry point) non ne trova nessuno: `build.ps1` genera `_strumenti.py`
|
||||
(MODULI, dagli entry point del venv) e `registra_strumenti()` li importa per nome (#82).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
from visionsuite import license as lic # contratto licenza v1: impronta, cerca_licenza, verifica
|
||||
|
||||
PRODOTTO = "tiemeasureflow"
|
||||
TITOLO = "TieMeasureFlow"
|
||||
PORTA_WEB = 8780
|
||||
PORTA_BACKEND = 8000
|
||||
PORTA_VISION = 8100
|
||||
PORTA_CAMERA = 8765
|
||||
log = logging.getLogger("tmf.avvio")
|
||||
|
||||
# Nuitka definisce __compiled__ nel modulo compilato; da sorgente non esiste
|
||||
COMPILATO = "__compiled__" in globals()
|
||||
# Da sorgente (python packaging/windows/avvio.py) la radice e' due livelli sopra questo file
|
||||
RADICE_SORGENTI = Path(__file__).resolve().parents[2]
|
||||
# Segreti generati al primo avvio e riletti dopo (nome -> generatore)
|
||||
SEGRETI = ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD")
|
||||
# STATION_CODE: senza una stazione l'interfaccia risponde 503; ST-DEFAULT e' quella creata dal seed della pagina di setup
|
||||
PREDEFINITI = {"STATION_CODE": "ST-DEFAULT"}
|
||||
|
||||
|
||||
def _stream_sicuri() -> None:
|
||||
# Exe senza console: stdout/stderr sono None e print/logging/fileConfig di alembic
|
||||
# andrebbero in errore; li si sostituisce con il device nullo
|
||||
if sys.stdout is None:
|
||||
sys.stdout = open(os.devnull, "w", encoding="utf-8")
|
||||
if sys.stderr is None:
|
||||
sys.stderr = open(os.devnull, "w", encoding="utf-8")
|
||||
|
||||
|
||||
def cartella_tielogic() -> Path:
|
||||
# %PROGRAMDATA% e' sempre impostata su Windows; il ripiego serve solo a sviluppo/test su altri sistemi
|
||||
base = os.environ.get("PROGRAMDATA") or str(Path.home() / ".tielogic-dati")
|
||||
return Path(base) / "Tielogic"
|
||||
|
||||
|
||||
def cartella_dati() -> Path:
|
||||
return cartella_tielogic() / "TieMeasureFlow"
|
||||
|
||||
|
||||
def cartella_programma() -> Path:
|
||||
# Cartella dell'exe (P); da sorgente, la radice del repo
|
||||
if COMPILATO:
|
||||
return Path(sys.argv[0]).resolve().parent
|
||||
return RADICE_SORGENTI
|
||||
|
||||
|
||||
def percorso_impronta() -> Path:
|
||||
return cartella_tielogic() / "licenze" / f"impronta-{PRODOTTO}.txt"
|
||||
|
||||
|
||||
def messaggio(testo: str, titolo: str = TITOLO, errore: bool = True, senza_finestre: bool = False) -> None:
|
||||
# Sempre su stderr (se esiste); la MessageBox solo su Windows e se non soppressa
|
||||
if sys.stderr is not None:
|
||||
print(testo, file=sys.stderr)
|
||||
if senza_finestre or sys.platform != "win32":
|
||||
return
|
||||
# MB_OK | MB_ICONERROR (0x10) oppure MB_ICONINFORMATION (0x40) | MB_TOPMOST (0x40000)
|
||||
ctypes.windll.user32.MessageBoxW(None, testo, titolo, 0x40000 | (0x10 if errore else 0x40))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- licenza
|
||||
|
||||
def _scrivi_impronta(impronta: str) -> None:
|
||||
try:
|
||||
p = percorso_impronta()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(impronta + "\n", encoding="utf-8")
|
||||
except OSError as e:
|
||||
log.warning("impronta non scritta: %s", e)
|
||||
|
||||
|
||||
def controlla_licenza(senza_finestre: bool = False) -> bool:
|
||||
"""True se la licenza e' valida; altrimenti mostra il motivo e l'impronta e torna False."""
|
||||
try:
|
||||
percorso = lic.cerca_licenza(PRODOTTO)
|
||||
lic.verifica(percorso, PRODOTTO)
|
||||
return True
|
||||
except lic.LicenzaNonValida as e:
|
||||
motivo = str(e)
|
||||
except Exception as e: # impronta non calcolabile, file illeggibile: mai un'eccezione muta nell'exe
|
||||
motivo = f"Controllo licenza non riuscito: {e}"
|
||||
try:
|
||||
impronta = lic.impronta()
|
||||
except Exception as e:
|
||||
impronta = None
|
||||
motivo += f"\nImpronta macchina non disponibile: {e}"
|
||||
testo = f"{motivo}\n\n"
|
||||
if impronta:
|
||||
_scrivi_impronta(impronta)
|
||||
testo += (f"Impronta di questo computer:\n{impronta}\n\n"
|
||||
f"Comunicala a Tielogic per ottenere la licenza (salvata anche in {percorso_impronta()}). "
|
||||
f"La licenza va copiata in {lic.cerca_licenza(PRODOTTO)}.")
|
||||
messaggio(testo, f"{TITOLO} - licenza", senza_finestre=senza_finestre)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- configurazione
|
||||
|
||||
def _leggi_env(percorso: Path) -> dict[str, str]:
|
||||
valori: dict[str, str] = {}
|
||||
for riga in percorso.read_text(encoding="utf-8").splitlines():
|
||||
riga = riga.strip()
|
||||
if riga and not riga.startswith("#") and "=" in riga:
|
||||
k, v = riga.split("=", 1)
|
||||
valori[k.strip()] = v.strip()
|
||||
return valori
|
||||
|
||||
|
||||
def carica_segreti(dati: Path) -> dict[str, str]:
|
||||
"""Legge <dati>\\config.env; i segreti mancanti si generano e si riscrivono (una volta sola)."""
|
||||
dati.mkdir(parents=True, exist_ok=True)
|
||||
percorso = dati / "config.env"
|
||||
valori = _leggi_env(percorso) if percorso.exists() else {}
|
||||
nuovi = [k for k in SEGRETI if not valori.get(k)]
|
||||
# valori di partenza modificabili dall'utente in config.env (scritti una volta sola)
|
||||
mancanti = [k for k in PREDEFINITI if k not in valori]
|
||||
if nuovi or mancanti:
|
||||
for k in nuovi:
|
||||
valori[k] = secrets.token_urlsafe(32)
|
||||
for k in mancanti:
|
||||
valori[k] = PREDEFINITI[k]
|
||||
righe = ["# Generato da TieMeasureFlow al primo avvio. Non condividere questo file.",
|
||||
"# SETUP_PASSWORD serve per la pagina http://127.0.0.1:8000/api/setup (primo amministratore)."]
|
||||
righe += [f"{k}={v}" for k, v in valori.items()]
|
||||
percorso.write_text("\n".join(righe) + "\n", encoding="utf-8")
|
||||
log.info("segreti generati in %s", percorso)
|
||||
return valori
|
||||
|
||||
|
||||
def trova_browser() -> str | None:
|
||||
"""Chrome o Edge per kaleido (grafici dei report PDF, offline)."""
|
||||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||||
local = os.environ.get("LOCALAPPDATA", "")
|
||||
candidati = [
|
||||
Path(pf) / "Google/Chrome/Application/chrome.exe",
|
||||
Path(pf86) / "Google/Chrome/Application/chrome.exe",
|
||||
Path(local) / "Google/Chrome/Application/chrome.exe" if local else None,
|
||||
Path(pf86) / "Microsoft/Edge/Application/msedge.exe",
|
||||
Path(pf) / "Microsoft/Edge/Application/msedge.exe",
|
||||
]
|
||||
for c in candidati:
|
||||
if c is not None and c.is_file():
|
||||
return str(c)
|
||||
return None
|
||||
|
||||
|
||||
def imposta_ambiente(dati: Path, host: str, valori: dict[str, str]) -> None:
|
||||
"""Variabili d'ambiente del contratto v1; da chiamare PRIMA di importare backend e frontend."""
|
||||
uploads = dati / "uploads"
|
||||
env = {
|
||||
"APP_ENV": "production",
|
||||
# barre "/" nel percorso: l'URL SQLAlchemy non vuole le barre rovesciate
|
||||
"DB_URL": f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}",
|
||||
"UPLOAD_DIR": str(uploads),
|
||||
"VISION_WORKER_URL": f"http://127.0.0.1:{PORTA_VISION}",
|
||||
"VISION_RECIPES_ROOT": str(uploads),
|
||||
"API_SERVER_URL": f"http://127.0.0.1:{PORTA_BACKEND}",
|
||||
"SESSION_COOKIE_SECURE": "0", # niente HTTPS in locale: un cookie Secure verrebbe scartato
|
||||
"SERVER_CORS_ORIGINS": f"http://127.0.0.1:{PORTA_WEB},http://localhost:{PORTA_WEB}",
|
||||
**{k: valori[k] for k in SEGRETI},
|
||||
}
|
||||
# le altre chiavi di config.env (STATION_CODE, STATION_SWITCH_ENABLED, ...) passano cosi' come sono,
|
||||
# ma non sovrascrivono le variabili fisse del contratto
|
||||
for k, v in valori.items():
|
||||
env.setdefault(k, v)
|
||||
programma = cartella_programma()
|
||||
versione = programma / "vision-engine-version.txt" # scritto da build.ps1
|
||||
if versione.is_file():
|
||||
env["VISION_ENGINE_VERSION"] = versione.read_text(encoding="utf-8").strip()
|
||||
gtk = programma / "gtk" # DLL di WeasyPrint (piano B)
|
||||
if gtk.is_dir():
|
||||
env["WEASYPRINT_DLL_DIRECTORIES"] = str(gtk)
|
||||
if hasattr(os, "add_dll_directory"):
|
||||
# cintura e bretelle: WeasyPrint salta add_dll_directory se trova sys.frozen
|
||||
try:
|
||||
os.add_dll_directory(str(gtk))
|
||||
except OSError:
|
||||
pass
|
||||
browser = trova_browser()
|
||||
if browser:
|
||||
env["BROWSER_PATH"] = browser
|
||||
os.environ.update(env)
|
||||
for cartella in (uploads, dati / "camera", dati / "log"):
|
||||
cartella.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _prepara_percorsi_sorgenti() -> None:
|
||||
# Da sorgente i moduli del frontend (config, services, blueprints, app) e lo script camera
|
||||
# non sono pacchetti: si aggiungono al path. Compilati, Nuitka li ha gia' incorporati.
|
||||
if COMPILATO:
|
||||
return
|
||||
for p in (RADICE_SORGENTI / "scripts", RADICE_SORGENTI / "src" / "frontend" / "flask_app", RADICE_SORGENTI):
|
||||
if str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- log
|
||||
|
||||
def _prepara_log(dati: Path | None = None) -> None:
|
||||
# Exe senza console: il log va su file; se la cartella non e' scrivibile si prosegue senza.
|
||||
# Idempotente: alembic (fileConfig in env.py) azzera i gestori del root, quindi si richiama dopo.
|
||||
try:
|
||||
d = (dati or cartella_dati()) / "log"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
percorso = str(d / "tiemeasureflow.log")
|
||||
root = logging.getLogger()
|
||||
for h in root.handlers:
|
||||
if isinstance(h, logging.FileHandler) and h.baseFilename.lower() == os.path.abspath(percorso).lower():
|
||||
break
|
||||
else:
|
||||
h = logging.FileHandler(percorso, encoding="utf-8")
|
||||
h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
||||
root.addHandler(h)
|
||||
root.setLevel(logging.INFO)
|
||||
for nome in ("tmf.avvio",):
|
||||
logging.getLogger(nome).disabled = False # fileConfig disattiva i logger esistenti
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- strumenti di visione
|
||||
|
||||
def registra_strumenti() -> list[str]:
|
||||
"""Registra gli strumenti di Core; torna i moduli che non si sono importati (#82).
|
||||
|
||||
Nel pacchetto Nuitka gli entry point non esistono: `load_tools()` non trova nulla e
|
||||
l'elenco dei moduli arriva da `_strumenti.py`, generato da build.ps1. Da sorgente
|
||||
`_strumenti` non c'e' e bastano gli entry point. Il worker di visione gira in un thread
|
||||
di questo processo: registrare una volta sola qui vale per tutti."""
|
||||
from visionsuite.task.registry import load_tools
|
||||
load_tools()
|
||||
try:
|
||||
from _strumenti import MODULI # generato da build.ps1, accanto ad avvio.py
|
||||
except ImportError:
|
||||
MODULI = () # da sorgente: gli entry point di load_tools() bastano
|
||||
falliti: list[str] = []
|
||||
for modulo in MODULI:
|
||||
try:
|
||||
importlib.import_module(modulo) # eseguire il modulo registra i suoi @tool
|
||||
except Exception: # un modulo rotto non ferma l'app: si logga e si raccoglie
|
||||
log.error("strumenti %s non importati", modulo, exc_info=True)
|
||||
falliti.append(modulo)
|
||||
return falliti
|
||||
|
||||
|
||||
def controlla_strumenti() -> int:
|
||||
"""Senza licenza, server ne' DB: scrive il JSON del catalogo strumenti. Exit 0 se completo, 3 altrimenti."""
|
||||
from visionsuite.task.graph import registry
|
||||
from visionsuite.task.registry import unavailable
|
||||
falliti = registra_strumenti()
|
||||
kind = sorted(registry())
|
||||
esito = {"strumenti": len(kind), "kind": kind, "falliti": falliti, "unavailable": unavailable()}
|
||||
riga = json.dumps(esito, ensure_ascii=False)
|
||||
if sys.stdout is not None:
|
||||
print(riga)
|
||||
try:
|
||||
f = cartella_dati() / "log" / "controllo-strumenti.json"
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_text(riga + "\n", encoding="utf-8") # l'exe e' senza console: il file e' l'unica uscita sicura
|
||||
except OSError as e:
|
||||
log.warning("controllo-strumenti.json non scritto: %s", e)
|
||||
return 0 if kind and not falliti else 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- rete
|
||||
|
||||
def _porta_aperta(porta: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", porta), timeout=0.5):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _e_tmf() -> bool:
|
||||
# Una copia gia' in esecuzione: il backend risponde su /api/health con il nome del servizio
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{PORTA_BACKEND}/api/health", timeout=2) as r:
|
||||
return "TieMeasureFlow" in str(json.load(r).get("service", ""))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _nessun_utente(db: Path) -> bool:
|
||||
"""True se il database esiste ed e' senza utenti (primo avvio: serve la pagina di setup)."""
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
|
||||
try:
|
||||
return con.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 0
|
||||
finally:
|
||||
con.close()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
|
||||
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
|
||||
for _ in range(300):
|
||||
if all(_porta_aperta(p) for p in porte):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
webbrowser.open(url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- migrazioni
|
||||
|
||||
def _neutralizza_fileconfig() -> None:
|
||||
# env.py di Alembic chiama logging.config.fileConfig (anche la pagina di setup, con command.stamp):
|
||||
# azzererebbe i gestori del log su file. Nel pacchetto il log lo decide avvio.py.
|
||||
import logging.config
|
||||
logging.config.fileConfig = lambda *a, **k: None
|
||||
|
||||
|
||||
def esegui_migrazioni() -> None:
|
||||
"""alembic upgrade head. Compilato: env.py e versions\\ sono soli .pyc, quindi sourceless."""
|
||||
_neutralizza_fileconfig()
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
import src.backend as pacchetto
|
||||
migrazioni = Path(pacchetto.__file__).resolve().parent / "migrations"
|
||||
cfg = Config(str(migrazioni / "alembic.ini"))
|
||||
if COMPILATO:
|
||||
cfg.set_main_option("sourceless", "true")
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- server
|
||||
|
||||
class ServerThread(threading.Thread):
|
||||
"""Un server (uvicorn o waitress) in un thread; `errore` resta valorizzato se muore."""
|
||||
|
||||
def __init__(self, nome: str, costruisci):
|
||||
super().__init__(name=nome, daemon=True)
|
||||
self._costruisci = costruisci
|
||||
self.server = None
|
||||
self.errore: BaseException | None = None
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.server = self._costruisci()
|
||||
self.server.run()
|
||||
except BaseException as e: # un server morto deve comparire nel log e fermare l'insieme
|
||||
self.errore = e
|
||||
log.exception("server %s terminato con errore", self.name)
|
||||
|
||||
def ferma(self) -> None:
|
||||
s = self.server
|
||||
try:
|
||||
if hasattr(s, "should_exit"):
|
||||
s.should_exit = True # uvicorn
|
||||
elif s is not None:
|
||||
s.close() # waitress
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _uvicorn(app, porta: int):
|
||||
import uvicorn
|
||||
# log_config=None: niente console; i thread non toccano i signal handler (uvicorn lo gestisce)
|
||||
return uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=porta, log_config=None, workers=1))
|
||||
|
||||
|
||||
def costruisci_web(host: str):
|
||||
import waitress
|
||||
from app import create_app # src/frontend/flask_app/app.py
|
||||
return waitress.create_server(create_app(), host=host, port=PORTA_WEB, threads=8)
|
||||
|
||||
|
||||
def costruisci_backend():
|
||||
from src.backend.main import app
|
||||
return _uvicorn(app, PORTA_BACKEND)
|
||||
|
||||
|
||||
def costruisci_vision():
|
||||
from src.vision_worker.main import app
|
||||
return _uvicorn(app, PORTA_VISION)
|
||||
|
||||
|
||||
def costruisci_camera(cartella: Path):
|
||||
from camera_service import create_app # scripts/camera_service.py (non e' un pacchetto)
|
||||
return _uvicorn(create_app(cartella), PORTA_CAMERA)
|
||||
|
||||
|
||||
def avvia_server(host: str, dati: Path) -> list[ServerThread]:
|
||||
thread = [
|
||||
ServerThread("backend", costruisci_backend),
|
||||
ServerThread("vision", costruisci_vision),
|
||||
ServerThread("camera", lambda: costruisci_camera(dati / "camera")),
|
||||
ServerThread("web", lambda: costruisci_web(host)),
|
||||
]
|
||||
for t in thread:
|
||||
t.start()
|
||||
return thread
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
_stream_sicuri()
|
||||
p = argparse.ArgumentParser(prog="TieMeasureFlow", description=TITOLO)
|
||||
p.add_argument("--impronta", action="store_true", help="mostra l'impronta di questo computer")
|
||||
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
|
||||
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
|
||||
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
|
||||
p.add_argument("--controlla-strumenti", action="store_true",
|
||||
help="registra gli strumenti, scrive il JSON ed esce (senza licenza ne' server; usata da build.ps1)")
|
||||
args = p.parse_args(argv)
|
||||
_prepara_log()
|
||||
|
||||
if args.impronta:
|
||||
try:
|
||||
impronta = lic.impronta()
|
||||
except Exception as e:
|
||||
messaggio(f"Impronta non disponibile: {e}", senza_finestre=args.senza_finestre)
|
||||
return 1
|
||||
print(impronta) if sys.stdout is not None else None
|
||||
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
|
||||
return 0
|
||||
|
||||
if args.controlla_strumenti:
|
||||
return controlla_strumenti() # prima della licenza: la build non ha una licenza
|
||||
|
||||
if not controlla_licenza(args.senza_finestre):
|
||||
return 1
|
||||
|
||||
if _porta_aperta(PORTA_WEB):
|
||||
if _e_tmf():
|
||||
# TieMeasureFlow e' gia' in esecuzione: basta riaprire la pagina
|
||||
if not args.senza_browser:
|
||||
webbrowser.open(f"http://127.0.0.1:{PORTA_WEB}/")
|
||||
return 0
|
||||
messaggio(f"La porta {PORTA_WEB} e' occupata da un altro programma: chiuderlo e riprovare.",
|
||||
senza_finestre=args.senza_finestre)
|
||||
return 2
|
||||
occupate = [str(x) for x in (PORTA_BACKEND, PORTA_VISION, PORTA_CAMERA) if _porta_aperta(x)]
|
||||
if occupate:
|
||||
messaggio(f"Porte occupate da altri programmi: {', '.join(occupate)}. Chiuderli e riprovare.",
|
||||
senza_finestre=args.senza_finestre)
|
||||
return 2
|
||||
|
||||
dati = cartella_dati()
|
||||
try:
|
||||
valori = carica_segreti(dati)
|
||||
imposta_ambiente(dati, args.host, valori)
|
||||
_prepara_percorsi_sorgenti()
|
||||
primo_avvio = not (dati / "tmf.db").exists()
|
||||
esegui_migrazioni()
|
||||
except Exception as e:
|
||||
_prepara_log(dati)
|
||||
log.exception("avvio non riuscito")
|
||||
messaggio(f"Avvio non riuscito: {e}\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}",
|
||||
senza_finestre=args.senza_finestre)
|
||||
return 3
|
||||
_prepara_log(dati) # alembic ha riconfigurato il logging
|
||||
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
|
||||
|
||||
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
|
||||
thread = avvia_server(args.host, dati)
|
||||
if not args.senza_browser:
|
||||
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
||||
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
|
||||
else f"http://127.0.0.1:{PORTA_WEB}/")
|
||||
threading.Thread(target=_apri_browser_quando_pronto,
|
||||
args=(url, (PORTA_WEB, PORTA_BACKEND)), daemon=True).start()
|
||||
|
||||
# Si resta vivi finche' tutti i server girano; se uno muore si ferma tutto
|
||||
try:
|
||||
while thread and all(t.is_alive() for t in thread):
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
morti = [t for t in thread if not t.is_alive()]
|
||||
for t in thread:
|
||||
t.ferma()
|
||||
if morti:
|
||||
messaggio("Un componente si e' fermato: " + ", ".join(f"{t.name} ({t.errore})" for t in morti)
|
||||
+ f"\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}", senza_finestre=args.senza_finestre)
|
||||
return 4
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,247 @@
|
||||
<#
|
||||
Pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
|
||||
|
||||
Cosa fa: uv sync in un ambiente Python 3.13 separato (build\venv313), frontend (Tailwind +
|
||||
traduzioni), compila avvio.py con Nuitka in modalita' --standalone (cartella, NON onefile:
|
||||
l'onefile si decomprime in %TEMP% ad ogni avvio), aggiunge le migrazioni Alembic come soli
|
||||
.pyc, le DLL di WeasyPrint (piano B) e il commit del motore di visione, poi Inno Setup
|
||||
produce dist\TieMeasureFlow-setup-<versione>.exe. Sul PC del cliente non finisce nessun
|
||||
.py; i .pyc ci sono solo in src\backend\migrations (Alembic le carica dal disco).
|
||||
|
||||
Come si usa (dalla radice del repo, con il submodule vendor/visionsuite inizializzato):
|
||||
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 [-Versione 3.0.0]
|
||||
-Versione accetta anche un tag (v3.0.0, v3.0.0-rc1): si usano i numeri iniziali.
|
||||
Senza -Versione si usa la variabile GITHUB_REF_NAME (tag nella CI) o 0.0.0.
|
||||
-SenzaSetup si ferma dopo Nuitka (cartella build\nuitka\avvio.dist).
|
||||
|
||||
Dipende da: uv, node/npx (Tailwind 3.4.19 da npm: serve Internet SOLO a chi costruisce),
|
||||
Visual Studio con carico C++ (MSVC; Nuitka lo trova da solo), Inno Setup 6
|
||||
(`winget install --id JRSoftware.InnoSetup -e`; ISCC.exe nel PATH, in Program Files o in
|
||||
%LOCALAPPDATA%\Programs). Nuitka e' nel gruppo uv "windows" (pyproject.toml).
|
||||
|
||||
⚠️ La prima compilazione e' lunga (decine di minuti: numpy/opencv/scipy/plotly); le successive
|
||||
riusano la cache di Nuitka (%LOCALAPPDATA%\Nuitka) e la cartella build\nuitka.
|
||||
⚠️ I pacchetti di Core condividono il namespace visionsuite (extend_path): Nuitka non lo
|
||||
segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ installati.
|
||||
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
|
||||
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
|
||||
funziona ma e' piu' lento nel matching.
|
||||
⚠️ Gli strumenti di vs-tools si registrano solo come entry point (visionsuite.task.tools) e i
|
||||
metadati di vs-tools non stanno nel binario (TieMeasureFlow#82): il build genera
|
||||
packaging\windows\_strumenti.py (MODULI, gitignorato) che avvio.py importa, e prima di
|
||||
Inno Setup esegue l'exe con --controlla-strumenti: se il catalogo e' vuoto il build fallisce.
|
||||
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
|
||||
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
|
||||
#>
|
||||
param(
|
||||
[string]$Versione = $env:GITHUB_REF_NAME,
|
||||
[switch]$SenzaSetup
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
Set-Location $radice
|
||||
|
||||
# Versione numerica a 4 parti per i metadati dell'exe (Nuitka la vuole cosi')
|
||||
if (-not $Versione) { $Versione = '0.0.0' }
|
||||
if ($Versione -notmatch '^v?(\d+)\.(\d+)\.(\d+)') { throw "Versione non valida: '$Versione' (atteso es. v3.0.0)" }
|
||||
$ver3 = "$($Matches[1]).$($Matches[2]).$($Matches[3])"
|
||||
$ver4 = "$ver3.0"
|
||||
Write-Host "== TieMeasureFlow $ver3 =="
|
||||
|
||||
# Controlli: se manca qualcosa il messaggio dice come installarlo
|
||||
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||
throw "uv non trovato. Installarlo: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
}
|
||||
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||
throw "node/npx non trovato (serve a compilare Tailwind). Installarlo da https://nodejs.org"
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $radice 'vendor\visionsuite\packages\vs-license'))) {
|
||||
throw "Submodule vendor/visionsuite mancante o vecchio: git submodule update --init --recursive"
|
||||
}
|
||||
$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source
|
||||
if (-not $iscc) {
|
||||
foreach ($c in @("${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")) {
|
||||
if (Test-Path $c) { $iscc = $c; break }
|
||||
}
|
||||
}
|
||||
if (-not $iscc -and -not $SenzaSetup) { throw "Inno Setup 6 non trovato. Installarlo: winget install --id JRSoftware.InnoSetup -e" }
|
||||
|
||||
# Ambiente separato da quello di sviluppo (3.11): extra server + client + vision-worker + gruppo windows
|
||||
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313'
|
||||
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
|
||||
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
|
||||
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
|
||||
$vn = & $py -m nuitka --version # niente Select-Object -First: chiude la pipe e falsa l'exit code
|
||||
if ($LASTEXITCODE -ne 0) { throw "Nuitka non disponibile (serve anche un compilatore C: Visual Studio con 'Sviluppo di applicazioni desktop con C++')" }
|
||||
|
||||
# Frontend: tailwind.css non e' nel repo (stessa versione esatta di Dockerfile.frontend) e traduzioni .mo
|
||||
$flask = Join-Path $radice 'src\frontend\flask_app'
|
||||
Push-Location $flask
|
||||
try {
|
||||
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
|
||||
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') compile -d translations
|
||||
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
|
||||
} finally { Pop-Location }
|
||||
|
||||
# Sottopacchetti del namespace visionsuite (core, camera, task, tools, license, ...)
|
||||
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
|
||||
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
|
||||
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
|
||||
# Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui
|
||||
# pacchetto (visionsuite.camera, namespace) non e' di primo livello. Gli entry point di vs-tools, che
|
||||
# load_tools() cercherebbe nei metadati, si rimpiazzano con _strumenti.py (sotto, TieMeasureFlow#82).
|
||||
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
|
||||
|
||||
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
|
||||
# del LEGGIMI.md, con l'eventuale commento "# ..." in coda. I dati di src/backend/templates sono gia' sopra.
|
||||
$flagPdf = @()
|
||||
$leggimi = Join-Path $PSScriptRoot 'pdf\LEGGIMI.md'
|
||||
if (Test-Path $leggimi) {
|
||||
$flagPdf = @(Get-Content $leggimi | ForEach-Object {
|
||||
if ($_ -match '^\s*(--(?:include|nofollow|noinclude|enable)[^\s`#]*)\s*(?:#.*)?$' -and $Matches[1] -notlike '--include-data-dir=src/backend/templates*') { $Matches[1] }
|
||||
})
|
||||
Write-Host "Flag Nuitka da pdf\LEGGIMI.md: $($flagPdf -join ' ')"
|
||||
}
|
||||
|
||||
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
|
||||
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
|
||||
|
||||
# TieMeasureFlow#82: i moduli-strumento dagli entry point del venv del build (nel binario non ci sono i
|
||||
# metadati di vs-tools, quindi load_tools() non li troverebbe). Si usa $py, non `uv run`.
|
||||
$strumenti = Join-Path $radice 'packaging\windows\_strumenti.py'
|
||||
$genera = @'
|
||||
import sys
|
||||
from importlib.metadata import entry_points
|
||||
moduli = sorted({ep.value for ep in entry_points(group="visionsuite.task.tools")})
|
||||
if not moduli:
|
||||
sys.exit(1)
|
||||
righe = ["# Generato da build.ps1 (TieMeasureFlow#82): non modificare, non committare.", "MODULI = ("]
|
||||
righe += [f" {m!r}," for m in moduli] + [")"]
|
||||
with open(sys.argv[1], "w", encoding="utf-8", newline="") as f:
|
||||
print(*righe, sep=chr(10), file=f)
|
||||
print(len(moduli))
|
||||
'@
|
||||
$nModuli = $genera | & $py - $strumenti
|
||||
if ($LASTEXITCODE -ne 0) { throw "Entry point visionsuite.task.tools non trovati nel venv: nessuno strumento da includere" }
|
||||
Write-Host "Moduli-strumento ($nModuli) in $strumenti"
|
||||
|
||||
$out = Join-Path $radice 'build\nuitka'
|
||||
New-Item -ItemType Directory -Force $out | Out-Null
|
||||
$nuitka = @(
|
||||
'--standalone',
|
||||
'--assume-yes-for-downloads',
|
||||
'--msvc=latest',
|
||||
'--windows-console-mode=disable', # nessuna finestra nera: gli errori escono in MessageBox e nel log
|
||||
"--output-dir=$out",
|
||||
'--output-filename=TieMeasureFlow.exe',
|
||||
'--windows-icon-from-ico=packaging\windows\TieMeasureFlow.ico',
|
||||
'--company-name=Tielogic',
|
||||
'--product-name=TieMeasureFlow',
|
||||
"--file-version=$ver4",
|
||||
"--product-version=$ver4",
|
||||
'--file-description=TieMeasureFlow',
|
||||
'--copyright=Tielogic',
|
||||
# codice del prodotto: backend, worker di visione, runner (src e' un pacchetto, flask_app no)
|
||||
'--include-package=src.backend',
|
||||
'--include-package=src.vision',
|
||||
'--include-package=src.vision_worker',
|
||||
# le migrazioni NON si compilano: Alembic le carica dal disco, vanno come .pyc (sotto)
|
||||
'--nofollow-import-to=src.backend.migrations',
|
||||
'--nofollow-import-to=src.backend.tests',
|
||||
'--nofollow-import-to=src.vision.tests',
|
||||
'--nofollow-import-to=src.vision_worker.tests',
|
||||
# frontend Flask e servizio camera: moduli di primo livello trovati via PYTHONPATH
|
||||
'--include-module=app',
|
||||
'--include-module=config',
|
||||
'--include-package=blueprints',
|
||||
'--include-package=services',
|
||||
'--include-module=camera_service',
|
||||
'--nofollow-import-to=blueprints.tests',
|
||||
'--nofollow-import-to=tests',
|
||||
'--include-package=uvicorn', # loop e protocolli di uvicorn si caricano per nome
|
||||
'--include-package=waitress',
|
||||
'--include-package=alembic',
|
||||
'--include-module=aiosqlite',
|
||||
'--include-module=sqlalchemy.dialects.sqlite.aiosqlite',
|
||||
'--include-package=harvesters', # camere GenTL (GigE): il producer .cti e' del vendor
|
||||
'--include-package=genicam',
|
||||
'--include-package-data=genicam',
|
||||
'--include-package-data=babel', # locale-data di Babel (flask-babel)
|
||||
'--nofollow-import-to=numba', # vedi nota in testa: il ripiego in Python puro
|
||||
'--nofollow-import-to=llvmlite',
|
||||
# dati letti dal disco con __file__: setup page, report, Flask (templates/static/translations alla radice)
|
||||
'--include-data-dir=src\backend\templates=src\backend\templates',
|
||||
'--include-data-files=src\backend\migrations\alembic.ini=src\backend\migrations\alembic.ini',
|
||||
'--include-data-dir=src\frontend\flask_app\templates=templates',
|
||||
'--include-data-dir=src\frontend\flask_app\static=static',
|
||||
'--include-data-dir=src\frontend\flask_app\translations=translations',
|
||||
'--include-data-files=pyproject.toml=pyproject.toml' # config.py ne legge la versione per il piede di pagina
|
||||
) + $includi + $flagPdf + @('packaging\windows\avvio.py')
|
||||
|
||||
$t0 = Get-Date
|
||||
& $py -m nuitka @nuitka
|
||||
if ($LASTEXITCODE -ne 0) { throw "Nuitka fallito (codice $LASTEXITCODE)" }
|
||||
Write-Host ("Nuitka: {0:N0} minuti" -f ((Get-Date) - $t0).TotalMinutes)
|
||||
|
||||
$sorgente = Join-Path $out 'avvio.dist'
|
||||
if (-not (Test-Path (Join-Path $sorgente 'TieMeasureFlow.exe'))) { throw "exe non trovato in $sorgente" }
|
||||
|
||||
# Migrazioni Alembic: env.py e versions\ come soli .pyc (compileall -b: .pyc accanto al sorgente, che si cancella)
|
||||
$mig = Join-Path $sorgente 'src\backend\migrations'
|
||||
New-Item -ItemType Directory -Force (Join-Path $mig 'versions') | Out-Null
|
||||
Copy-Item 'src\backend\migrations\env.py' $mig -Force
|
||||
Copy-Item 'src\backend\migrations\versions\*.py' (Join-Path $mig 'versions') -Force
|
||||
& $py -m compileall -b -q $mig
|
||||
if ($LASTEXITCODE -ne 0) { throw "compileall delle migrazioni fallito" }
|
||||
Get-ChildItem $mig -Recurse -Filter *.py -File | Remove-Item -Force
|
||||
Get-ChildItem $mig -Recurse -Directory -Filter __pycache__ | Remove-Item -Recurse -Force
|
||||
|
||||
# alembic.ini del pacchetto: sourceless, perche' anche la pagina di setup (command.stamp) apre
|
||||
# alembic.ini con un Config suo e non troverebbe env.py/versions (solo .pyc)
|
||||
$ini = Join-Path $mig 'alembic.ini'
|
||||
$testoIni = [System.IO.File]::ReadAllText($ini)
|
||||
if ($testoIni -notmatch '(?m)^script_location') { throw "alembic.ini senza script_location" }
|
||||
$testoIni = $testoIni -replace '(?m)^(script_location[^\r\n]*)', "`$1`nsourceless = true"
|
||||
[System.IO.File]::WriteAllText($ini, $testoIni, (New-Object System.Text.UTF8Encoding $false))
|
||||
|
||||
# Commit del motore di visione: avvio.py lo legge in VISION_ENGINE_VERSION
|
||||
$motore = (git -C vendor/visionsuite rev-parse HEAD).Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or -not $motore) { throw "Commit di vendor/visionsuite non leggibile" }
|
||||
[System.IO.File]::WriteAllText((Join-Path $sorgente 'vision-engine-version.txt'), $motore, (New-Object System.Text.UTF8Encoding $false))
|
||||
|
||||
# DLL di WeasyPrint (piano PDF): lo script le scarica con sha256 pinnato
|
||||
$gtk = Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1'
|
||||
if (Test-Path $gtk) {
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File $gtk -Destinazione (Join-Path $sorgente 'gtk')
|
||||
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
|
||||
} else {
|
||||
Write-Warning "packaging\windows\pdf\prendi-gtk.ps1 assente: il pacchetto non avra' le DLL di WeasyPrint (niente report PDF)"
|
||||
}
|
||||
|
||||
# Il pacchetto non deve contenere codice sorgente o bytecode, tranne i .pyc delle migrazioni
|
||||
$sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
|
||||
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
|
||||
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
|
||||
|
||||
# TieMeasureFlow#82: l'exe deve vedere gli strumenti (--controlla-strumenti non vuole licenza ne' apre server/DB)
|
||||
$exe = Join-Path $sorgente 'TieMeasureFlow.exe'
|
||||
$jsonControllo = Join-Path $env:PROGRAMDATA 'Tielogic\TieMeasureFlow\log\controllo-strumenti.json'
|
||||
Remove-Item $jsonControllo -ErrorAction SilentlyContinue # un JSON vecchio non deve far passare il controllo
|
||||
# l'exe e' GUI-subsystem: con & non si aspetta, serve Start-Process -Wait
|
||||
$proc = Start-Process -FilePath $exe -ArgumentList '--controlla-strumenti' -Wait -PassThru
|
||||
$esito = if (Test-Path $jsonControllo) { Get-Content $jsonControllo -Raw } else { '(controllo-strumenti.json non scritto)' }
|
||||
if ($proc.ExitCode -ne 0) { throw "Controllo strumenti fallito (codice $($proc.ExitCode)): $esito" }
|
||||
$nStrumenti = ($esito | ConvertFrom-Json).strumenti
|
||||
Write-Host "Controllo strumenti: $nStrumenti tipi registrati nell'exe"
|
||||
|
||||
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
|
||||
|
||||
$dist = Join-Path $radice 'dist'
|
||||
New-Item -ItemType Directory -Force $dist | Out-Null
|
||||
& $iscc "/DVersione=$ver3" "/DVersione4=$ver4" "/DOrigine=$sorgente" "/DUscita=$dist" (Join-Path $radice 'packaging\windows\TieMeasureFlow.iss')
|
||||
if ($LASTEXITCODE -ne 0) { throw "Inno Setup fallito" }
|
||||
|
||||
$setup = Join-Path $dist "TieMeasureFlow-setup-$ver3.exe"
|
||||
Write-Host ("Pronto: {0} ({1:N1} MB)" -f $setup, ((Get-Item $setup).Length / 1MB))
|
||||
@@ -0,0 +1,3 @@
|
||||
# Le DLL scaricate da prendi-gtk.ps1 non si committano: solo lo script (TieMeasureFlow#78).
|
||||
gtk/
|
||||
cache/
|
||||
@@ -0,0 +1,149 @@
|
||||
# Report PDF nel pacchetto Windows
|
||||
|
||||
Questa cartella contiene quello che serve perché i report PDF di TieMeasureFlow
|
||||
(`src/backend/services/report_service.py`: Jinja2, Plotly con Kaleido 1.2.0, WeasyPrint 68.1)
|
||||
funzionino su Windows, dentro il pacchetto, **senza Internet**. Riferimento: TieMeasureFlow#78
|
||||
(parte del pacchetto Windows, madre #76).
|
||||
|
||||
## Come funziona
|
||||
|
||||
Un report SPC passa da due componenti nativi che su Windows non arrivano con `pip`:
|
||||
|
||||
1. **WeasyPrint** disegna il PDF e carica a runtime le DLL di Pango/GTK
|
||||
(`libgobject-2.0-0`, `libpango-1.0-0`, `libharfbuzz-0`, `libharfbuzz-subset-0`,
|
||||
`libfontconfig-1`, `libpangoft2-1.0-0` e le loro dipendenze).
|
||||
2. **Kaleido 1.x** trasforma i grafici Plotly in SVG pilotando un browser Chromium già installato
|
||||
(non porta più Chromium con sé). Su ogni Windows 10/11 c'è Edge.
|
||||
|
||||
### Le DLL: `prendi-gtk.ps1`
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\windows\pdf\prendi-gtk.ps1 -Destinazione <cartella>
|
||||
```
|
||||
|
||||
- Scarica da `repo.msys2.org` (ucrt64) 21 pacchetti **pinnati con sha256** (nome file e hash stanno nello
|
||||
script), verifica l'hash, estrae solo le 27 DLL necessarie, `etc\fonts` di fontconfig e le licenze.
|
||||
- Idempotente: se la cartella è già pronta (file `.gtk-pin` uguale ai pin dello script) esce subito.
|
||||
- Se un hash non coincide fallisce e non lascia mezza cartella.
|
||||
- I pacchetti scaricati stanno in `%LOCALAPPDATA%\Tielogic\tmf-gtk-cache` (`-Cache` per cambiarla).
|
||||
- Serve Internet **solo alla build**. Estrae `.zst` con `tar.exe` di Windows (verificato: bsdtar 3.8.9 con
|
||||
zstd); se `tar.exe` non sa di zstd ripiega su `uv run --with zstandard`.
|
||||
- Gira da utente non admin con PowerShell 5.1.
|
||||
- ⚠️ `repo.msys2.org` tiene solo le versioni recenti: se un file pinnato sparisce (404) vanno aggiornati nome
|
||||
e sha256 dal db `https://repo.msys2.org/mingw/ucrt64/ucrt64.db` e rifatto il test. Conservare la cache
|
||||
(Wasabi o NAS) evita la corsa contro il repository.
|
||||
|
||||
Il piano C copia `<cartella>` in `P\gtk\` (P = cartella programma).
|
||||
|
||||
### Variabili d'ambiente (le imposta `avvio.py`, piano C)
|
||||
|
||||
| Variabile | Valore | Perché |
|
||||
|---|---|---|
|
||||
| `WEASYPRINT_DLL_DIRECTORIES` | `<P>\gtk` | WeasyPrint chiama `os.add_dll_directory` su ogni cartella (separatore `;`) **prima** di caricare le DLL. Va impostata prima di importare `weasyprint` (lo fa `report_service` all'import). |
|
||||
| `BROWSER_PATH` | `msedge.exe` o `chrome.exe` | Choreographer (sotto Kaleido) la legge da sé e ha la precedenza su ogni ricerca (verificato: un percorso inesistente dà `FileNotFoundError`). Senza la variabile Kaleido cerca Edge/Chrome da solo (registro e percorsi tipici). Nessun codice in `report_service.py` per questo. |
|
||||
|
||||
Fontconfig non richiede variabili: trova `etc\fonts` accanto alle DLL (verificato). Se manca `etc\fonts`
|
||||
WeasyPrint stampa "Fontconfig error: Cannot load default config file". I font dei report vengono da
|
||||
`C:\Windows\Fonts`.
|
||||
|
||||
### MathJax: la modifica a `report_service.py`
|
||||
|
||||
Kaleido 1.x inserisce nella pagina di render uno `<script>` MathJax da `cdnjs.cloudflare.com` anche quando i
|
||||
grafici non hanno formule. Misurato su daitarn con Edge: con rete che scarta i pacchetti ogni grafico aspetta
|
||||
~19 s (due grafici per report SPC); con rete che rifiuta subito ~2 s. `report_service.py` imposta
|
||||
`pio.defaults.mathjax` su un file `.js` vuoto in `%TEMP%` (`tmf-mathjax-vuoto.js`): nessuna richiesta di rete,
|
||||
report SPC in ~7 s anche a rete bloccata. Se un giorno i grafici useranno LaTeX, qui serve un MathJax locale.
|
||||
`plotly.min.js` invece è già locale (dentro il pacchetto `plotly`).
|
||||
|
||||
## Flag Nuitka e dati da includere (per `build.ps1`, piano C)
|
||||
|
||||
⚠️ Stato: ricavati leggendo i sorgenti installati (versioni del `uv.lock`); **non provati con una build
|
||||
Nuitka**: la verifica è la prima build di C. I dati e i metadati sono quelli che il codice legge davvero da disco.
|
||||
|
||||
Dati dei pacchetti (file non `.py` letti a runtime):
|
||||
|
||||
```text
|
||||
--include-package-data=weasyprint # css/html5_ua.css, html5_ph.css, html5_ua_form.css, pdf/sRGB2014.icc
|
||||
--include-package-data=pyphen # dictionaries/*.dic (84 file, sillabazione)
|
||||
--include-package-data=kaleido # vendor/kaleido_scopes.js (pagina di render)
|
||||
--include-package-data=plotly # package_data/plotly.min.js (Kaleido lo cerca come file accanto a plotly/__init__), templates/*.json, validators/_validators.json
|
||||
--include-package-data=tzdata # zoneinfo: Windows non ha il database IANA (vedi sotto)
|
||||
--include-data-dir=src/backend/templates=src/backend/templates # Jinja2: report_service legge i .html dal disco
|
||||
```
|
||||
|
||||
`choreographer/resources/last_known_good_chrome.json` serve solo al comando che scarica Chrome: non serve.
|
||||
|
||||
Metadati delle distribuzioni (`importlib.metadata.version` al momento dell'import): senza, `import plotly` e
|
||||
Kaleido falliscono con `PackageNotFoundError`:
|
||||
|
||||
```text
|
||||
--include-distribution-metadata=plotly
|
||||
--include-distribution-metadata=kaleido
|
||||
```
|
||||
|
||||
Import dinamici che l'analisi statica di Nuitka può perdere:
|
||||
|
||||
```text
|
||||
--include-package=fontTools # ttLib/ttFont.py importa le tabelle con __import__("fontTools.ttLib.tables." + tag)
|
||||
--include-package=plotly # _plotly_utils/importers.py: importlib.import_module lazy dei moduli graph_objs
|
||||
--include-package=_plotly_utils
|
||||
--include-module=_cffi_backend # WeasyPrint usa cffi in modalita' ABI (dlopen), serve il modulo C di cffi
|
||||
--include-package=weasyprint
|
||||
--include-package=kaleido
|
||||
--include-package=choreographer
|
||||
```
|
||||
|
||||
Altre note per C:
|
||||
|
||||
- WeasyPrint salta `os.add_dll_directory` se `sys.frozen` esiste (`weasyprint/text/ffi.py`). Nuitka di norma
|
||||
non lo imposta (PyInstaller sì): se in `TieMeasureFlow.exe` le DLL non si trovassero, chiamare
|
||||
`os.add_dll_directory(<P>\gtk)` da `avvio.py` prima dell'import.
|
||||
- Le DLL **non** vanno compilate né incluse da Nuitka: restano file in `P\gtk\`.
|
||||
- Il controllo "nessun `.py`" di `build.ps1` non deve scandire `gtk\` (non ci sono `.py`, ma è meglio
|
||||
saperlo) né `licenze\`.
|
||||
- **`tzdata` manca da `pyproject.toml`**: su Windows `ZoneInfo("Europe/Rome")` (usato da
|
||||
`src/backend/utils/tempo.py`, quindi da ogni report) dà `ZoneInfoNotFoundError`. Va aggiunto
|
||||
`tzdata; sys_platform == 'win32'` agli extra server e client (o al gruppo `windows`) e il frontend
|
||||
(`src/frontend/flask_app/app.py` usa `ZoneInfo`) ne ha bisogno allo stesso modo.
|
||||
- `pdfplumber` è già nell'extra `server` (il test lo usa per leggere i PDF).
|
||||
|
||||
## Licenze delle DLL
|
||||
|
||||
Le DLL sono binari MSYS2/MinGW (ucrt64) non modificati, collegati dinamicamente. `prendi-gtk.ps1` mette in
|
||||
`<cartella>\licenze\` i testi di licenza di ogni pacchetto più un `<pacchetto>.txt` con nome del file e licenza.
|
||||
**L'installer deve includere `gtk\licenze\`.**
|
||||
|
||||
| Libreria | Licenza |
|
||||
|---|---|
|
||||
| Pango, GLib/GObject/GIO, fribidi, libthai, libdatrie, graphite2, libintl (gettext-runtime), libiconv | LGPL 2.1 o successive |
|
||||
| FreeType | FTL oppure GPL 2+ (si sceglie FTL) |
|
||||
| HarfBuzz, expat, libffi, brotli, fontconfig | MIT / permissiva |
|
||||
| libpng, zlib-ng, bzip2, pcre2 | permissive (libpng, Zlib, bzip2, BSD-3) |
|
||||
| libgcc, libstdc++ | GPL 3 con GCC Runtime Library Exception (ridistribuzione libera) |
|
||||
| libwinpthread | MIT e BSD-3 |
|
||||
|
||||
Per le librerie LGPL, chi riceve il pacchetto può sostituire le DLL (sono file separati in `gtk\`); i sorgenti
|
||||
sono pubblici su MSYS2 (`https://packages.msys2.org/`), e l'elenco esatto delle versioni è nello script.
|
||||
|
||||
## Test
|
||||
|
||||
`src/backend/tests/test_report_windows.py` (saltato fuori da Windows, senza DLL, senza Edge/Chrome o senza
|
||||
`tzdata`): in un sottoprocesso con WeasyPrint **vero** (il `conftest.py` altrimenti lo sostituisce con un
|
||||
mock) genera su SQLite in memoria il report SPC (carta di controllo e istogramma) e quello a tabella, e
|
||||
controlla con `pdfplumber` byte `%PDF-`, almeno una pagina, titoli dei grafici e segni vettoriali.
|
||||
|
||||
```powershell
|
||||
# DLL (una volta) e test
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\windows\pdf\prendi-gtk.ps1 -Destinazione packaging\windows\pdf\gtk
|
||||
uv run --python 3.13 --extra server --extra dev pytest src/backend/tests/test_report_windows.py -q
|
||||
```
|
||||
|
||||
`packaging/windows/pdf/gtk/` e `cache/` sono in `.gitignore`: le DLL non si committano.
|
||||
|
||||
### Prova a rete bloccata
|
||||
|
||||
`HTTP_PROXY`/`HTTPS_PROXY` non bastano: Edge e Chrome usano il **proxy di sistema** di Windows. Per provare davvero
|
||||
il PC senza Internet: impostare temporaneamente il proxy di sistema (Impostazioni, Rete, Proxy) su un indirizzo
|
||||
morto (`127.0.0.1:9` rifiuta subito; `10.255.255.1:8080` scarta i pacchetti, il caso peggiore), generare i
|
||||
report e **ripristinare** il proxy. Esito del 2026-10-17 con proxy che scarta i pacchetti: report SPC e report a
|
||||
tabella completi in ~7 s.
|
||||
@@ -0,0 +1,143 @@
|
||||
<#
|
||||
prendi-gtk.ps1 - prepara le DLL di Pango/GTK che WeasyPrint 68.1 vuole su Windows.
|
||||
|
||||
COSA FA: scarica da repo.msys2.org (ucrt64) un elenco FISSO di pacchetti, ne verifica lo sha256,
|
||||
estrae solo le DLL che servono a WeasyPrint (+ etc\fonts di fontconfig e le licenze) in -Destinazione.
|
||||
COME SI USA: powershell -NoProfile -ExecutionPolicy Bypass -File prendi-gtk.ps1 -Destinazione <cartella>
|
||||
Opzionali: -Cache <cartella> (pacchetti scaricati, default %LOCALAPPDATA%\Tielogic\tmf-gtk-cache),
|
||||
-Forza (rifa' anche se la cartella e' gia' pronta).
|
||||
DA COSA DIPENDE: Windows 10/11 con tar.exe (bsdtar con zstd) oppure uv + Python (modulo zstandard);
|
||||
Internet solo al momento della build, mai a runtime. PowerShell 5.1 va bene, non serve admin.
|
||||
IDEMPOTENTE: se la cartella ha gia' le DLL e il file .gtk-pin coincide con questi pin, esce senza scaricare.
|
||||
FALLISCE se lo sha256 di un pacchetto non coincide (nessun file parziale resta in -Destinazione).
|
||||
TRAPPOLA: repo.msys2.org tiene solo le versioni recenti. Se un file pinnato sparisce (404) serve aggiornare
|
||||
i pin qui sotto (nome file + sha256 dal db https://repo.msys2.org/mingw/ucrt64/ucrt64.db) e riprovare i test.
|
||||
La -Cache conserva i pacchetti gia' scaricati: copiarla su Wasabi/NAS evita il problema.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Destinazione,
|
||||
[string]$Cache = (Join-Path $env:LOCALAPPDATA 'Tielogic\tmf-gtk-cache'),
|
||||
[switch]$Forza
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# PS 5.1 usa TLS 1.0 di default: repo.msys2.org vuole 1.2 (cosa/perche')
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$Base = 'https://repo.msys2.org/mingw/ucrt64/'
|
||||
|
||||
# Pin: pacchetto, file, sha256 (dal db ucrt64 di MSYS2 del 2026-10-17), licenza dichiarata.
|
||||
$Pacchetti = @(
|
||||
@{ N='brotli'; F='mingw-w64-ucrt-x86_64-brotli-1.2.0-1-any.pkg.tar.zst'; H='9cc89665496ea504751476eafba15c71c554aaf3004babc2004f7c07ffd60514'; L='MIT' }
|
||||
@{ N='bzip2'; F='mingw-w64-ucrt-x86_64-bzip2-1.0.8-4-any.pkg.tar.zst'; H='f03a2174034ddd2d96cecd34f617c5f8e2ef86c812b8d2bb3b8875257f2c8bfa'; L='bzip2 (BSD)' }
|
||||
@{ N='expat'; F='mingw-w64-ucrt-x86_64-expat-2.9.0-1-any.pkg.tar.zst'; H='f52364dc874009316e1ee1fe46ee89784594b80d10cbcc1cc856945d575308e0'; L='MIT' }
|
||||
@{ N='fontconfig'; F='mingw-w64-ucrt-x86_64-fontconfig-2.18.3-1-any.pkg.tar.zst'; H='d0b364f3ee37e3b6f6c136688ce8fa909bb6d8a69df7e08de1aa40870d9e1d60'; L='fontconfig (MIT-like)' }
|
||||
@{ N='freetype'; F='mingw-w64-ucrt-x86_64-freetype-2.15.0-1-any.pkg.tar.zst'; H='a9a691f71d1a553099c54ca666e611abfb2b4ff08dae24f2a5af72e22a7e5feb'; L='GPL-2.0+ OR FTL' }
|
||||
@{ N='fribidi'; F='mingw-w64-ucrt-x86_64-fribidi-1.0.17-1-any.pkg.tar.zst'; H='3581ee03cf83a9bb0e98bf688c4f532a29553500e317532d90746de295b0f8a8'; L='LGPL-2.1+' }
|
||||
@{ N='gettext-runtime'; F='mingw-w64-ucrt-x86_64-gettext-runtime-1.0-1-any.pkg.tar.zst'; H='ba693dda4ac375af76ce481ff3a6e7481286546cc7dc6d56c7021dae34084157'; L='LGPL-2.1+ (libintl)' }
|
||||
@{ N='glib2'; F='mingw-w64-ucrt-x86_64-glib2-2.90.1-1-any.pkg.tar.zst'; H='9a7826349279bcca0c935b01880d812be9de9609aef2205be68fd7a6128c1695'; L='LGPL-2.1+' }
|
||||
@{ N='graphite2'; F='mingw-w64-ucrt-x86_64-graphite2-1.3.15-1-any.pkg.tar.zst'; H='34ca1f88bff10ef182a6b1c8e5622d5029f13b05748e361af13b552b422ea849'; L='LGPL-2.1+' }
|
||||
@{ N='harfbuzz'; F='mingw-w64-ucrt-x86_64-harfbuzz-14.6.0-1-any.pkg.tar.zst'; H='8781d486c864bf91f482768cc5bd7267e49331636583d9f855af3974e6378e69'; L='MIT' }
|
||||
@{ N='libdatrie'; F='mingw-w64-ucrt-x86_64-libdatrie-0.2.14-1-any.pkg.tar.zst'; H='ea92777801ad325278193a362967540752ae37035016df2f6bcee2d89928a195'; L='LGPL' }
|
||||
@{ N='libffi'; F='mingw-w64-ucrt-x86_64-libffi-3.8.0-1-any.pkg.tar.zst'; H='99ad12f4ecfa00a889ef9c5c0188592368a5bac109d0c508ce4e04f29ea95a76'; L='MIT' }
|
||||
@{ N='libgcc'; F='mingw-w64-ucrt-x86_64-libgcc-16.2.0-6-any.pkg.tar.zst'; H='80b2497f019977dd9528b1abc8621b1328a0115ab1f724047ccbb56bf323f11d'; L='GPL-3.0+ con GCC runtime exception' }
|
||||
@{ N='libiconv'; F='mingw-w64-ucrt-x86_64-libiconv-1.19-1-any.pkg.tar.zst'; H='9a500f38c2b91808741c62fae746b3e9110b33a1ecf5c30fa0c66dbedddf7e16'; L='LGPL-2.1+' }
|
||||
@{ N='libpng'; F='mingw-w64-ucrt-x86_64-libpng-1.6.59-1-any.pkg.tar.zst'; H='7f8448e06eceba8f8ce130390addea49f767bdf8f6b7e7eede8ca6d595e3d3a8'; L='libpng (permissiva)' }
|
||||
@{ N='libstdc++'; F='mingw-w64-ucrt-x86_64-libstdc++-16.2.0-6-any.pkg.tar.zst'; H='33c5e58a85a538cf843a8672991cce591f0231d66da3b66a57629712d64be17f'; L='GPL-3.0+ con GCC runtime exception' }
|
||||
@{ N='libthai'; F='mingw-w64-ucrt-x86_64-libthai-0.1.30-1-any.pkg.tar.zst'; H='c90ebbca3e7b4c34f4b3ffa501ba8d00cdaf6340cff7db3c46d79d5e01ef207c'; L='LGPL-2.1+' }
|
||||
@{ N='libwinpthread'; F='mingw-w64-ucrt-x86_64-libwinpthread-14.0.0.r426.g4564ee4b5-2-any.pkg.tar.zst'; H='a02f5e09d666d3be657c78c86fe67c975d04fe33b5211cd5ff1b23a91c118524'; L='MIT AND BSD-3-Clause' }
|
||||
@{ N='pcre2'; F='mingw-w64-ucrt-x86_64-pcre2-10.49-1-any.pkg.tar.zst'; H='63172179db724ac71e2439a7981134c1bcab8049bdddfd349b32b8f76ed52d1e'; L='BSD-3-Clause' }
|
||||
@{ N='pango'; F='mingw-w64-ucrt-x86_64-pango-1.58.2-1-any.pkg.tar.zst'; H='8d86e4a018ff139b010bf357fa1c9f9030fa32ee195c54ed7e89d8ea701851c8'; L='LGPL-2.1' }
|
||||
@{ N='zlib-ng-compat'; F='mingw-w64-ucrt-x86_64-zlib-ng-compat-2.3.3-2-any.pkg.tar.zst'; H='b3ca6b437fb154a0fa26652002cdda591498ee4a0e08d91cc653c3702d090f25'; L='Zlib' }
|
||||
)
|
||||
|
||||
# DLL che WeasyPrint carica (6 radici) piu' la loro chiusura di dipendenze (calcolata con pefile il 2026-10-17).
|
||||
$Dll = @(
|
||||
'libbrotlicommon.dll','libbrotlidec.dll','libbz2-1.dll','libdatrie-1.dll','libexpat-1.dll','libffi-8.dll',
|
||||
'libfontconfig-1.dll','libfreetype-6.dll','libfribidi-0.dll','libgcc_s_seh-1.dll','libgio-2.0-0.dll',
|
||||
'libglib-2.0-0.dll','libgmodule-2.0-0.dll','libgobject-2.0-0.dll','libgraphite2.dll','libharfbuzz-0.dll',
|
||||
'libharfbuzz-subset-0.dll','libiconv-2.dll','libintl-8.dll','libpango-1.0-0.dll','libpangoft2-1.0-0.dll',
|
||||
'libpcre2-8-0.dll','libpng16-16.dll','libstdc++-6.dll','libthai-0.dll','libwinpthread-1.dll','zlib1.dll'
|
||||
)
|
||||
|
||||
function Scrivi([string]$m) { Write-Host ("[prendi-gtk] " + $m) }
|
||||
|
||||
# Impronta dei pin: se cambia, la cartella gia' pronta non e' piu' valida.
|
||||
$pinTesto = (($Pacchetti | ForEach-Object { $_.F + ':' + $_.H }) -join "`n") + "`n" + ($Dll -join ',')
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
$impronta = -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($pinTesto)) | ForEach-Object { $_.ToString('x2') })
|
||||
|
||||
$marker = Join-Path $Destinazione '.gtk-pin'
|
||||
$dllMancanti = @($Dll | Where-Object { -not (Test-Path (Join-Path $Destinazione $_)) })
|
||||
if (-not $Forza -and (Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $impronta) -and $dllMancanti.Count -eq 0) {
|
||||
Scrivi "gia' pronto in $Destinazione (pin $($impronta.Substring(0,12)))"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- estrazione .zst: tar.exe di Windows se sa di zstd, altrimenti Python zstandard via uv ---
|
||||
$tar = Join-Path $env:SystemRoot 'System32\tar.exe'
|
||||
$tarZstd = $false
|
||||
if (Test-Path $tar) { $tarZstd = ((& $tar --version) -join ' ') -match 'zstd' }
|
||||
function Estrai([string]$pkg, [string]$dest) {
|
||||
if ($tarZstd) {
|
||||
& $tar -xf $pkg -C $dest
|
||||
if ($LASTEXITCODE -ne 0) { throw "tar.exe ha fallito su $pkg" }
|
||||
} else {
|
||||
$py = "import sys,tarfile,zstandard`nd=zstandard.ZstdDecompressor()`nwith open(sys.argv[1],'rb') as f, d.stream_reader(f) as r:`n tarfile.open(fileobj=r,mode='r|').extractall(sys.argv[2])"
|
||||
$tmpPy = Join-Path ([IO.Path]::GetTempPath()) ('estrai-zst-' + [guid]::NewGuid().ToString('N') + '.py')
|
||||
Set-Content -Path $tmpPy -Value $py -Encoding ASCII
|
||||
try {
|
||||
& uv run --quiet --with zstandard python -I $tmpPy $pkg $dest
|
||||
if ($LASTEXITCODE -ne 0) { throw "estrazione zstd via uv fallita su $pkg" }
|
||||
}
|
||||
finally { Remove-Item $tmpPy -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Cache | Out-Null
|
||||
# Lavoro in una cartella temporanea, poi sposto: un errore a meta' non lascia mezza cartella in -Destinazione.
|
||||
$tmp = Join-Path ([IO.Path]::GetTempPath()) ('tmf-gtk-' + [guid]::NewGuid().ToString('N'))
|
||||
$stage = Join-Path $tmp 'stage'
|
||||
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
||||
try {
|
||||
foreach ($p in $Pacchetti) {
|
||||
$file = Join-Path $Cache $p.F
|
||||
$ok = $false
|
||||
if (Test-Path $file) {
|
||||
$ok = ((Get-FileHash $file -Algorithm SHA256).Hash.ToLower() -eq $p.H)
|
||||
if (-not $ok) { Scrivi "cache corrotta, riscarico $($p.F)"; Remove-Item $file -Force }
|
||||
}
|
||||
if (-not $ok) {
|
||||
Scrivi "scarico $($p.F)"
|
||||
$parz = $file + '.parz'
|
||||
# il '+' di libstdc++ va codificato nell'URL
|
||||
Invoke-WebRequest -UseBasicParsing -Uri ($Base + [Uri]::EscapeDataString($p.F)) -OutFile $parz
|
||||
$h = (Get-FileHash $parz -Algorithm SHA256).Hash.ToLower()
|
||||
if ($h -ne $p.H) { Remove-Item $parz -Force; throw "sha256 diverso per $($p.F): atteso $($p.H), trovato $h" }
|
||||
Move-Item $parz $file -Force
|
||||
}
|
||||
$ex = Join-Path $tmp $p.N
|
||||
New-Item -ItemType Directory -Force -Path $ex | Out-Null
|
||||
Estrai $file $ex
|
||||
$bin = Join-Path $ex 'ucrt64\bin'
|
||||
if (Test-Path $bin) { Get-ChildItem $bin -Filter *.dll | Copy-Item -Destination $stage -Force }
|
||||
# fontconfig: senza etc\fonts WeasyPrint non trova i font (errore "Cannot load default config file")
|
||||
if ($p.N -eq 'fontconfig') { Copy-Item (Join-Path $ex 'ucrt64\etc\fonts') (Join-Path $stage 'etc\fonts') -Recurse -Force }
|
||||
# licenze: le DLL LGPL si ridistribuiscono con il testo della licenza
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'licenze') | Out-Null
|
||||
$lic = Join-Path $ex 'ucrt64\share\licenses'
|
||||
if (Test-Path $lic) { Copy-Item $lic (Join-Path $stage ('licenze\' + $p.N)) -Recurse -Force }
|
||||
Set-Content -Path (Join-Path $stage ('licenze\' + $p.N + '.txt')) -Value ($p.F + "`nlicenza: " + $p.L + "`nsorgente: https://packages.msys2.org/") -Encoding UTF8
|
||||
}
|
||||
# Tengo solo le DLL in whitelist: le altre DLL dei pacchetti non servono.
|
||||
Get-ChildItem $stage -Filter *.dll | Where-Object { $Dll -notcontains $_.Name } | Remove-Item -Force
|
||||
$mancanti = @($Dll | Where-Object { -not (Test-Path (Join-Path $stage $_)) })
|
||||
if ($mancanti.Count -gt 0) { throw "DLL attese non trovate: $($mancanti -join ', ')" }
|
||||
Set-Content -Path (Join-Path $stage '.gtk-pin') -Value $impronta -Encoding ASCII
|
||||
|
||||
if (Test-Path $Destinazione) { Get-ChildItem $Destinazione -Force | Remove-Item -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $Destinazione | Out-Null
|
||||
Get-ChildItem $stage -Force | Move-Item -Destination $Destinazione -Force
|
||||
Scrivi "pronto: $($Dll.Count) DLL in $Destinazione"
|
||||
}
|
||||
finally {
|
||||
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Test di packaging/windows/avvio.py (TieMeasureFlow#76): licenza firmata con la chiave di prova
|
||||
del contratto v1, nessun server vero (avvia_server e migrazioni sono sostituiti).
|
||||
|
||||
Si lanciano con l'ambiente 3.13 del pacchetto (serve visionsuite.license):
|
||||
UV_PROJECT_ENVIRONMENT=build/venv313 uv run --python 3.13 --frozen --extra server --extra client \
|
||||
--extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import types
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
RADICE = Path(__file__).resolve().parents[3]
|
||||
# vettore di prova del contratto licenza v1 (chiave finta, solo test)
|
||||
SEED_TEST = base64.b64decode("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
|
||||
PUBBLICA_TEST = base64.b64decode("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
|
||||
IMPRONTA = "ab" * 32
|
||||
|
||||
|
||||
def _firma(dati: dict) -> str:
|
||||
# stessa canonicalizzazione del contratto (byte firmati)
|
||||
byte = json.dumps(dati, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
return base64.b64encode(Ed25519PrivateKey.from_private_bytes(SEED_TEST).sign(byte)).decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def avvio(monkeypatch, tmp_path):
|
||||
# avvio.py non e' in un pacchetto: lo si carica dal percorso
|
||||
spec = importlib.util.spec_from_file_location("avvio_tmf", RADICE / "packaging/windows/avvio.py")
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
monkeypatch.setenv("PROGRAMDATA", str(tmp_path)) # ProgramData finta
|
||||
monkeypatch.delenv("TIELOGIC_LICENZA", raising=False)
|
||||
monkeypatch.setattr(os, "environ", os.environ.copy()) # le variabili impostate da avvio non escono dal test
|
||||
monkeypatch.setattr(m.lic, "impronta", lambda: IMPRONTA)
|
||||
# verifica reale (formato, firma, prodotto, macchina, scadenza) ma con la chiave di prova
|
||||
verifica_reale = m.lic.verifica
|
||||
monkeypatch.setattr(m.lic, "verifica", functools.partial(
|
||||
verifica_reale, chiave_pubblica=PUBBLICA_TEST, impronta_attesa=IMPRONTA))
|
||||
m.chiamate = []
|
||||
monkeypatch.setattr(m, "avvia_server", lambda host, dati: m.chiamate.append((host, dati)) or [])
|
||||
monkeypatch.setattr(m, "esegui_migrazioni", lambda: m.chiamate.append("migrazioni"))
|
||||
m.aperti = []
|
||||
monkeypatch.setattr(m.webbrowser, "open", lambda url: m.aperti.append(url))
|
||||
monkeypatch.setattr(m, "_prepara_log", lambda dati=None: None)
|
||||
monkeypatch.setattr(m, "_porta_aperta", lambda porta: False)
|
||||
return m
|
||||
|
||||
|
||||
def _scrivi_licenza(tmp_path: Path, **modifiche) -> Path:
|
||||
dati = {"versione": 1, "prodotto": "tiemeasureflow", "cliente": "Prova Srl", "impronta": IMPRONTA,
|
||||
"emessa": date.today().isoformat(), "scadenza": (date.today() + timedelta(days=30)).isoformat()}
|
||||
dati.update(modifiche)
|
||||
p = tmp_path / "Tielogic" / "licenze" / "tiemeasureflow.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps({"dati": dati, "firma": _firma(dati)}), encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def test_licenza_mancante_non_avvia_e_scrive_impronta(avvio, capsys):
|
||||
assert avvio.main(["--senza-finestre"]) == 1
|
||||
assert avvio.chiamate == [] and avvio.aperti == []
|
||||
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
|
||||
err = capsys.readouterr().err
|
||||
assert "Licenza non trovata" in err and IMPRONTA in err and "tiemeasureflow.json" in err
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modifica,testo", [
|
||||
({"scadenza": "2020-01-01"}, "scaduta"),
|
||||
({"impronta": "cd" * 32}, "altra macchina"),
|
||||
({"prodotto": "tievision-studio"}, "altro prodotto"),
|
||||
])
|
||||
def test_licenza_non_valida_non_avvia(avvio, tmp_path, capsys, modifica, testo):
|
||||
_scrivi_licenza(tmp_path, **modifica)
|
||||
assert avvio.main(["--senza-finestre"]) == 1
|
||||
assert avvio.chiamate == []
|
||||
assert testo in capsys.readouterr().err
|
||||
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
|
||||
|
||||
|
||||
def test_licenza_manomessa_non_avvia(avvio, tmp_path, capsys):
|
||||
p = _scrivi_licenza(tmp_path)
|
||||
doc = json.loads(p.read_text(encoding="utf-8"))
|
||||
doc["dati"]["cliente"] = "Altro Cliente" # dati cambiati dopo la firma
|
||||
p.write_text(json.dumps(doc), encoding="utf-8")
|
||||
assert avvio.main(["--senza-finestre"]) == 1
|
||||
assert "Firma" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_licenza_valida_prepara_dati_e_avvia(avvio, tmp_path):
|
||||
_scrivi_licenza(tmp_path)
|
||||
assert avvio.main(["--senza-finestre", "--senza-browser", "--host", "0.0.0.0"]) == 0
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
# prima le migrazioni, poi i server
|
||||
assert avvio.chiamate == ["migrazioni", ("0.0.0.0", dati)]
|
||||
assert (dati / "config.env").is_file() and (dati / "uploads").is_dir() and (dati / "camera").is_dir()
|
||||
assert not avvio.percorso_impronta().exists() # con licenza valida non si scrive nulla
|
||||
assert avvio.aperti == []
|
||||
|
||||
|
||||
def test_variabili_del_contratto(avvio, tmp_path):
|
||||
_scrivi_licenza(tmp_path)
|
||||
avvio.main(["--senza-finestre", "--senza-browser"])
|
||||
e = os.environ
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
assert e["APP_ENV"] == "production"
|
||||
assert e["DB_URL"] == f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}" and "\\" not in e["DB_URL"]
|
||||
assert e["UPLOAD_DIR"] == str(dati / "uploads") == e["VISION_RECIPES_ROOT"]
|
||||
assert e["VISION_WORKER_URL"] == "http://127.0.0.1:8100"
|
||||
assert e["API_SERVER_URL"] == "http://127.0.0.1:8000"
|
||||
assert e["SESSION_COOKIE_SECURE"] == "0"
|
||||
for k in ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD"):
|
||||
assert len(e[k]) >= 32
|
||||
|
||||
|
||||
def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path):
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
primo = avvio.carica_segreti(dati)
|
||||
testo = (dati / "config.env").read_text(encoding="utf-8")
|
||||
assert set(primo) == {"SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD", "STATION_CODE"}
|
||||
assert len({primo[k] for k in avvio.SEGRETI}) == 4 # segreti distinti
|
||||
assert primo["STATION_CODE"] == "ST-DEFAULT"
|
||||
secondo = avvio.carica_segreti(dati)
|
||||
assert secondo == primo and (dati / "config.env").read_text(encoding="utf-8") == testo
|
||||
|
||||
|
||||
def test_config_env_completa_i_segreti_mancanti(avvio, tmp_path):
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
dati.mkdir(parents=True)
|
||||
(dati / "config.env").write_text("# nota\nSETUP_PASSWORD=scelta-da-me\n", encoding="utf-8")
|
||||
v = avvio.carica_segreti(dati)
|
||||
assert v["SETUP_PASSWORD"] == "scelta-da-me" and len(v["SERVER_SECRET_KEY"]) >= 32
|
||||
assert avvio.carica_segreti(dati) == v
|
||||
|
||||
|
||||
def test_seconda_istanza_apre_solo_il_browser(avvio, tmp_path, monkeypatch):
|
||||
_scrivi_licenza(tmp_path)
|
||||
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||
monkeypatch.setattr(avvio, "_e_tmf", lambda: True)
|
||||
assert avvio.main(["--senza-finestre"]) == 0
|
||||
assert avvio.aperti == ["http://127.0.0.1:8780/"] and avvio.chiamate == []
|
||||
|
||||
|
||||
def test_porta_occupata_da_altro_programma(avvio, tmp_path, monkeypatch, capsys):
|
||||
_scrivi_licenza(tmp_path)
|
||||
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||
monkeypatch.setattr(avvio, "_e_tmf", lambda: False)
|
||||
assert avvio.main(["--senza-finestre"]) == 2
|
||||
assert "8780" in capsys.readouterr().err and avvio.chiamate == []
|
||||
|
||||
|
||||
def test_migrazione_fallita_esce_con_3(avvio, tmp_path, monkeypatch, capsys):
|
||||
_scrivi_licenza(tmp_path)
|
||||
|
||||
def rotta():
|
||||
raise RuntimeError("db bloccato")
|
||||
|
||||
monkeypatch.setattr(avvio, "esegui_migrazioni", rotta)
|
||||
assert avvio.main(["--senza-finestre"]) == 3
|
||||
assert "db bloccato" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_impronta_si_stampa_senza_licenza(avvio, capsys):
|
||||
assert avvio.main(["--impronta", "--senza-finestre"]) == 0
|
||||
assert IMPRONTA in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_nessun_utente_rileva_il_primo_avvio(avvio, tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
assert avvio._nessun_utente(db) is False # file assente: nessuna conclusione
|
||||
con = sqlite3.connect(db)
|
||||
con.execute("CREATE TABLE users (id INTEGER)")
|
||||
con.commit()
|
||||
assert avvio._nessun_utente(db) is True
|
||||
con.execute("INSERT INTO users VALUES (1)")
|
||||
con.commit()
|
||||
con.close()
|
||||
assert avvio._nessun_utente(db) is False
|
||||
|
||||
|
||||
def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(avvio, tmp_path):
|
||||
_scrivi_licenza(tmp_path)
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
dati.mkdir(parents=True)
|
||||
(dati / "config.env").write_text("STATION_CODE=ST-FIERA\nSTATION_SWITCH_ENABLED=1\nAPP_ENV=dev\n", encoding="utf-8")
|
||||
avvio.main(["--senza-finestre", "--senza-browser"])
|
||||
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
|
||||
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
|
||||
|
||||
|
||||
# --- TieMeasureFlow#82: strumenti nel pacchetto (nel binario non ci sono gli entry point) ---
|
||||
|
||||
def _modulo_strumenti(monkeypatch, moduli):
|
||||
# finto _strumenti.py generato da build.ps1
|
||||
finto = types.ModuleType("_strumenti")
|
||||
finto.MODULI = tuple(moduli)
|
||||
monkeypatch.setitem(sys.modules, "_strumenti", finto)
|
||||
|
||||
|
||||
def test_registra_strumenti_da_sorgente_senza_modulo_generato(avvio, monkeypatch):
|
||||
# _strumenti.py esiste solo dopo build.ps1: se manca (sorgente) bastano gli entry point
|
||||
monkeypatch.setitem(sys.modules, "_strumenti", None) # None in sys.modules = ImportError all'import
|
||||
from visionsuite.task.graph import registry
|
||||
assert avvio.registra_strumenti() == []
|
||||
assert len(registry()) > 0
|
||||
|
||||
|
||||
def test_registra_strumenti_modulo_inesistente_non_solleva(avvio, monkeypatch):
|
||||
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image", "visionsuite.tools.non_esiste"))
|
||||
assert avvio.registra_strumenti() == ["visionsuite.tools.non_esiste"] # raccolto, non sollevato
|
||||
|
||||
|
||||
def test_registra_strumenti_importa_i_moduli_elencati(avvio, monkeypatch):
|
||||
# senza entry point (come nell'exe) i moduli di MODULI sono l'unica via: li si importa per nome
|
||||
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image",))
|
||||
visti = []
|
||||
monkeypatch.setattr(avvio.importlib, "import_module", lambda nome: visti.append(nome))
|
||||
assert avvio.registra_strumenti() == [] and visti == ["visionsuite.tools.image"]
|
||||
|
||||
|
||||
def test_controlla_strumenti_senza_licenza_scrive_json(avvio, monkeypatch, capsys, tmp_path):
|
||||
monkeypatch.setitem(sys.modules, "_strumenti", None)
|
||||
|
||||
# la licenza non deve essere nemmeno chiesta, e server e migrazioni non devono partire
|
||||
def mai(*a, **k):
|
||||
raise AssertionError("licenza, server o migrazioni chiamati da --controlla-strumenti")
|
||||
|
||||
monkeypatch.setattr(avvio.lic, "verifica", mai)
|
||||
monkeypatch.setattr(avvio.lic, "cerca_licenza", mai)
|
||||
monkeypatch.setattr(avvio, "avvia_server", mai)
|
||||
monkeypatch.setattr(avvio, "esegui_migrazioni", mai)
|
||||
assert avvio.main(["--controlla-strumenti", "--senza-finestre"]) == 0
|
||||
dati = json.loads(capsys.readouterr().out)
|
||||
assert dati["strumenti"] > 0 and dati["strumenti"] == len(dati["kind"])
|
||||
assert dati["falliti"] == []
|
||||
scritto = tmp_path / "Tielogic" / "TieMeasureFlow" / "log" / "controllo-strumenti.json"
|
||||
assert json.loads(scritto.read_text(encoding="utf-8")) == dati
|
||||
assert avvio.aperti == [] and not (tmp_path / "Tielogic" / "TieMeasureFlow" / "tmf.db").exists() # nessun DB
|
||||
|
||||
|
||||
def test_controlla_strumenti_exit_3_se_un_modulo_fallisce(avvio, monkeypatch, capsys):
|
||||
_modulo_strumenti(monkeypatch, ("visionsuite.tools.non_esiste",))
|
||||
assert avvio.main(["--controlla-strumenti"]) == 3
|
||||
assert json.loads(capsys.readouterr().out)["falliti"] == ["visionsuite.tools.non_esiste"]
|
||||
|
||||
|
||||
def test_controlla_strumenti_exit_3_se_il_catalogo_e_vuoto(avvio, monkeypatch, capsys):
|
||||
# niente strumenti registrati (es. entry point assenti e _strumenti vuoto): il controllo deve fallire
|
||||
import visionsuite.task.graph as graph
|
||||
monkeypatch.setattr(avvio, "registra_strumenti", lambda: [])
|
||||
monkeypatch.setattr(graph, "registry", lambda: {})
|
||||
assert avvio.main(["--controlla-strumenti"]) == 3
|
||||
assert json.loads(capsys.readouterr().out)["strumenti"] == 0
|
||||
|
||||
|
||||
def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path, monkeypatch):
|
||||
# main() chiama registra_strumenti() dopo licenza e migrazioni e prima di avvia_server
|
||||
_scrivi_licenza(tmp_path)
|
||||
monkeypatch.setattr(avvio, "registra_strumenti", lambda: avvio.chiamate.append("strumenti") or [])
|
||||
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
|
||||
@@ -63,6 +63,8 @@ vision = [
|
||||
"vs-task; python_version>='3.13'",
|
||||
"vs-measure; python_version>='3.13'",
|
||||
"vs-pm2d; python_version>='3.13'",
|
||||
# #46: tutti gli strumenti della suite (locate/MatchPattern e dxf/CompareDxf)
|
||||
"vs-tools[locate,dxf]; python_version>='3.13'",
|
||||
"numpy; python_version>='3.13'",
|
||||
]
|
||||
|
||||
@@ -79,6 +81,20 @@ vision-worker = [
|
||||
"python-multipart>=0.0.6; python_version>='3.13'",
|
||||
]
|
||||
|
||||
# Pacchetto Windows (packaging/windows, TieMeasureFlow#76): solo per build.ps1, non per
|
||||
# i container. Si usa con --python 3.13 insieme agli extra server, client e vision-worker.
|
||||
[dependency-groups]
|
||||
windows = [
|
||||
"tzdata; sys_platform == 'win32'", # ZoneInfo("Europe/Rome") (backend e frontend): Windows non ha il database IANA
|
||||
"aiosqlite>=0.20.0", # database SQLite su file (DB_URL, piano A)
|
||||
"waitress>=3.0.0", # server WSGI del frontend Flask (gunicorn non gira su Windows)
|
||||
"vs-camera[genicam]; python_version>='3.13'", # scripts/camera_service.py: camere GigE/GenTL (producer .cti del vendor)
|
||||
"vs-license; python_version>='3.13'", # licenza Tielogic v1 (impronta macchina, firma Ed25519)
|
||||
"nuitka>=2.5", # compilazione standalone
|
||||
"ordered-set>=4.1", # richiesto da Nuitka per la modalita' standalone
|
||||
"zstandard>=0.22", # compressione dei dati incorporati di Nuitka
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
# Backend
|
||||
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
||||
@@ -101,6 +117,10 @@ visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
|
||||
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
|
||||
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
|
||||
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
||||
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
|
||||
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
|
||||
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true } # #76 camera di stazione
|
||||
vs-license = { path = "vendor/visionsuite/packages/vs-license", editable = true } # #76 licenza del pacchetto Windows
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Servizio camera minimo di stazione (TieMeasureFlow, #72).
|
||||
|
||||
Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
|
||||
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
||||
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
||||
GET /camera/sources -> {"sources": [{name, kind, label}]}
|
||||
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG
|
||||
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
||||
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
|
||||
raggiunge; questo servizio permette di scegliere l'host di ascolto.
|
||||
|
||||
Uso (dalla radice del repo):
|
||||
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \
|
||||
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
|
||||
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
||||
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
|
||||
|
||||
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
||||
vs-camera, fastapi, uvicorn, opencv.
|
||||
|
||||
AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
|
||||
camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
from fastapi import Body, FastAPI, HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from visionsuite.camera.camera_interface import CameraError, CameraGrabError
|
||||
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
|
||||
|
||||
_TRIGGER = ("continuo",)
|
||||
|
||||
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
|
||||
_MESSAGGI = {
|
||||
"Camera is not open": "La camera non è aperta",
|
||||
"Camera is not grabbing": "La camera non sta acquisendo",
|
||||
"Grab cancelled (grabbing stopped)": "Acquisizione annullata (acquisizione fermata)",
|
||||
"Failed to read frame from RTSP stream": "Lettura del fotogramma dallo stream RTSP non riuscita",
|
||||
}
|
||||
_PREFISSI = (
|
||||
("Failed to open OpenCV device", "Apertura del dispositivo OpenCV non riuscita"),
|
||||
("Failed to open RTSP stream", "Apertura dello stream RTSP non riuscita"),
|
||||
("Failed to reconnect to RTSP stream", "Riconnessione allo stream RTSP non riuscita"),
|
||||
("Failed to read frame after", "Lettura del fotogramma non riuscita dopo"),
|
||||
)
|
||||
|
||||
|
||||
def _traduci(messaggio: str) -> str:
|
||||
if messaggio in _MESSAGGI:
|
||||
return _MESSAGGI[messaggio]
|
||||
for inglese, italiano in _PREFISSI:
|
||||
if messaggio.startswith(inglese):
|
||||
return italiano + messaggio[len(inglese):]
|
||||
return messaggio
|
||||
|
||||
|
||||
def create_app(folder: Path | str | None = None) -> FastAPI:
|
||||
aperte: dict = {}
|
||||
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||
|
||||
def _config() -> dict:
|
||||
if folder is None:
|
||||
return {}
|
||||
return {"folder": {"folder": str(folder), "loop": True}}
|
||||
|
||||
def _lock_di(nome: str) -> threading.Lock:
|
||||
with lock:
|
||||
return lock_sorgenti.setdefault(nome, threading.Lock())
|
||||
|
||||
@asynccontextmanager
|
||||
async def _vita(_app):
|
||||
yield
|
||||
with lock: # alla chiusura rilascia le camere aperte
|
||||
sorgenti = list(aperte.values())
|
||||
aperte.clear()
|
||||
for s in sorgenti:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
app = FastAPI(title="TMF camera service", lifespan=_vita)
|
||||
|
||||
@app.get("/camera/sources")
|
||||
def camera_sources():
|
||||
voci = list_sources(None, cameras=True, config=_config())
|
||||
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual")]}
|
||||
|
||||
@app.post("/camera/grab")
|
||||
def camera_grab(corpo: dict = Body(default=None)):
|
||||
nome = corpo.get("source") if isinstance(corpo, dict) else None
|
||||
if not isinstance(nome, str) or not nome.strip():
|
||||
raise HTTPException(400, "manca «source»")
|
||||
nome = nome.strip()
|
||||
trigger = corpo.get("trigger", "continuo")
|
||||
if trigger not in _TRIGGER:
|
||||
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
||||
timeout = corpo.get("timeout_ms", 5000)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||
try:
|
||||
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
|
||||
with lock:
|
||||
sorgente = aperte.get(nome)
|
||||
if sorgente is None:
|
||||
sorgente = open_source(nome, None, config=_config())
|
||||
with lock:
|
||||
aperte[nome] = sorgente
|
||||
frame = sorgente.grab(timeout)
|
||||
except SourceNotFound as e:
|
||||
raise HTTPException(404, _traduci(str(e)))
|
||||
except TimeoutError as e:
|
||||
raise HTTPException(504, _traduci(str(e)) or "timeout")
|
||||
except (CameraGrabError, CameraError) as e:
|
||||
raise HTTPException(503, _traduci(str(e)))
|
||||
ok, buf = cv2.imencode(".png", frame)
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica PNG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/png")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
p = argparse.ArgumentParser(description="Servizio camera minimo di stazione (#72)")
|
||||
p.add_argument("--host", default="127.0.0.1")
|
||||
p.add_argument("--port", type=int, default=8765)
|
||||
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder")
|
||||
a = p.parse_args()
|
||||
uvicorn.run(create_app(a.folder), host=a.host, port=a.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Test di scripts/camera_service.py (#72).
|
||||
|
||||
Uso: vendor/visionsuite/.venv/bin/python -m pytest scripts/tests/test_camera_service.py -q
|
||||
Dipende da: venv di VisionSuite (vs-camera, fastapi, httpx, opencv, pytest).
|
||||
"""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||
|
||||
|
||||
def _carica():
|
||||
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella(tmp_path):
|
||||
for nome, (w, h) in {"a.png": (64, 48), "b.png": (64, 48)}.items():
|
||||
cv2.imwrite(str(tmp_path / nome), np.full((h, w, 3), 128, np.uint8))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(cartella):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella)) as c:
|
||||
c.mod = mod
|
||||
yield c
|
||||
|
||||
|
||||
def test_sources_contiene_folder(client):
|
||||
r = client.get("/camera/sources")
|
||||
assert r.status_code == 200
|
||||
nomi = [s["name"] for s in r.json()["sources"]]
|
||||
assert "camera:folder" in nomi
|
||||
voce = next(s for s in r.json()["sources"] if s["name"] == "camera:folder")
|
||||
assert {"name", "kind", "label"} <= set(voce)
|
||||
|
||||
|
||||
def test_grab_folder_restituisce_png(client):
|
||||
r = client.post("/camera/grab", json={"source": "camera:folder"})
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/png"
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert img.shape[:2] == (48, 64)
|
||||
|
||||
|
||||
def test_grab_ripetuto_in_loop(client):
|
||||
# due immagini in cartella, tre grab: il loop non deve esaurirsi
|
||||
for _ in range(3):
|
||||
assert client.post("/camera/grab", json={"source": "camera:folder"}).status_code == 200
|
||||
|
||||
|
||||
def test_source_mancante_400(client):
|
||||
assert client.post("/camera/grab", json={}).status_code == 400
|
||||
assert client.post("/camera/grab", json={"source": " "}).status_code == 400
|
||||
|
||||
|
||||
def test_source_inesistente_404(client):
|
||||
assert client.post("/camera/grab", json={"source": "camera:non-esiste"}).status_code == 404
|
||||
|
||||
|
||||
def test_timeout_non_valido_400(client):
|
||||
r = client.post("/camera/grab", json={"source": "camera:folder", "timeout_ms": 0})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_errore_camera_503_tradotto(client, monkeypatch):
|
||||
from visionsuite.camera.camera_interface import CameraGrabError
|
||||
|
||||
class Rotta:
|
||||
def grab(self, timeout):
|
||||
raise CameraGrabError("Camera is not open")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Rotta())
|
||||
r = client.post("/camera/grab", json={"source": "camera:finta"})
|
||||
assert r.status_code == 503
|
||||
assert r.json()["detail"] == "La camera non è aperta"
|
||||
|
||||
|
||||
def test_timeout_504(client, monkeypatch):
|
||||
class Lenta:
|
||||
def grab(self, timeout):
|
||||
raise TimeoutError("scaduto")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Lenta())
|
||||
r = client.post("/camera/grab", json={"source": "camera:lenta"})
|
||||
assert r.status_code == 504
|
||||
|
||||
|
||||
def test_sorgente_aperta_una_volta(client, monkeypatch):
|
||||
aperture = []
|
||||
reale = client.mod.open_source
|
||||
|
||||
def conta(*a, **k):
|
||||
aperture.append(a[0])
|
||||
return reale(*a, **k)
|
||||
|
||||
monkeypatch.setattr(client.mod, "open_source", conta)
|
||||
for _ in range(3):
|
||||
client.post("/camera/grab", json={"source": "camera:folder"})
|
||||
assert aperture == ["camera:folder"]
|
||||
@@ -35,6 +35,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
LOGIN_PATH = "/api/auth/login"
|
||||
# #59: il login con PIN e' lo stesso attacco (4 cifre: ancora piu' facile da indovinare)
|
||||
LOGIN_PIN_PATH = "/api/auth/login-pin"
|
||||
WINDOW_SECONDS = 60
|
||||
|
||||
# Paths that carry a supervisor's (capoturno) credentials in the request
|
||||
@@ -97,7 +99,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
return True
|
||||
if method != "POST":
|
||||
return False
|
||||
if path == cls.LOGIN_PATH:
|
||||
if path in (cls.LOGIN_PATH, cls.LOGIN_PIN_PATH):
|
||||
return True
|
||||
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Authentication router - login, logout, profile."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
@@ -8,10 +9,17 @@ from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
PinLoginRequest,
|
||||
PinUserItem,
|
||||
UserProfileUpdate,
|
||||
UserResponse,
|
||||
)
|
||||
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
|
||||
from src.backend.services.auth_service import (
|
||||
authenticate_pin,
|
||||
authenticate_user,
|
||||
login_user,
|
||||
logout_user,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
@@ -32,6 +40,35 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pin-users", response_model=list[PinUserItem])
|
||||
async def pin_users(db: AsyncSession = Depends(get_db)):
|
||||
"""Utenti che possono entrare con PIN (#59): attivi, non admin, con PIN.
|
||||
|
||||
Pubblico per costruzione (serve prima del login): espone solo id e nome.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.active == True, # noqa: E712
|
||||
User.is_admin == False, # noqa: E712
|
||||
User.pin_hash.is_not(None),
|
||||
)
|
||||
)
|
||||
items = [
|
||||
PinUserItem(id=u.id, display_name=u.display_name or u.username)
|
||||
for u in result.scalars().all()
|
||||
]
|
||||
items.sort(key=lambda i: i.display_name.casefold())
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/login-pin", response_model=LoginResponse)
|
||||
async def login_pin(data: PinLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Login con user_id e PIN a 4 cifre (#59); stessa risposta del login."""
|
||||
user = await authenticate_pin(db, data.user_id, data.pin)
|
||||
api_key = await login_user(db, user)
|
||||
return LoginResponse(user=UserResponse.model_validate(user), api_key=api_key)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
request: Request,
|
||||
|
||||
@@ -61,7 +61,8 @@ def detect_file_type(content: bytes) -> tuple[str, str] | None:
|
||||
|
||||
# Top-level folders nothing may DELETE through this router: statistics/reports are
|
||||
# audit records, the logo is only ever replaced via POST /api/settings/logo.
|
||||
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/")
|
||||
# task_results/: allegati degli esiti dei task (#55), record di audit.
|
||||
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/", "task_results/")
|
||||
|
||||
# GET under these needs a role that is meant to see production data, not just
|
||||
# any authenticated user (a Maker or MeasurementTec has no business reading
|
||||
|
||||
@@ -147,6 +147,7 @@ async def authorise_measurement(
|
||||
|
||||
supervisor = await auth_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||
)
|
||||
measurement = await measurement_service.authorise_measurement(
|
||||
db, measurement, supervisor,
|
||||
|
||||
@@ -20,12 +20,14 @@ 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 auth_service, production_service
|
||||
from src.backend.models.api.monitor import MonitorRun
|
||||
from src.backend.services import auth_service, monitor_service, production_service
|
||||
|
||||
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||
|
||||
_DERIVED = {
|
||||
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
|
||||
"close_task_ids",
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +54,8 @@ async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunRes
|
||||
measurement_task_ids=await production_service.measurement_task_ids(
|
||||
db, run.version_id,
|
||||
),
|
||||
# #53: task da compilare alla fine produzione
|
||||
close_task_ids=await production_service.close_task_ids(db, run.version_id),
|
||||
)
|
||||
|
||||
|
||||
@@ -81,6 +85,27 @@ async def get_current_production_run(
|
||||
return await _as_response(db, run) if run is not None else None
|
||||
|
||||
|
||||
async def _require_high_authority(user: User = Depends(get_current_user)) -> User:
|
||||
"""Monitor of all stations (#57): admin or Supervisor (capoturno) only."""
|
||||
if user.is_admin or user.has_role("Supervisor"):
|
||||
return user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Supervisor role required",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/open", response_model=list[MonitorRun])
|
||||
async def list_open_production_runs(
|
||||
user: User = Depends(_require_high_authority),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Every open run with timer, last event and recent measurements (#57).
|
||||
|
||||
Declared before /{run_id} so "open" is not parsed as a run id.
|
||||
"""
|
||||
return await monitor_service.list_open_runs(db)
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
||||
async def get_production_run(
|
||||
run_id: int,
|
||||
@@ -141,6 +166,7 @@ async def pause_production_run(
|
||||
run = await production_service.get_run(db, run_id)
|
||||
supervisor = await auth_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||
)
|
||||
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
||||
return await _as_response(db, run)
|
||||
@@ -157,6 +183,7 @@ async def resume_production_run(
|
||||
run = await production_service.get_run(db, run_id)
|
||||
supervisor = await auth_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||
)
|
||||
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
||||
return await _as_response(db, run)
|
||||
@@ -173,6 +200,7 @@ async def close_production_run(
|
||||
run = await production_service.get_run(db, run_id)
|
||||
supervisor = await auth_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||
)
|
||||
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
||||
return await _as_response(db, run)
|
||||
|
||||
@@ -9,6 +9,7 @@ from src.backend.api.middleware.api_key import get_current_user, require_maker,
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.recipe import (
|
||||
RecipeCreate,
|
||||
RecipeDuplicate,
|
||||
RecipeListResponse,
|
||||
RecipeResponse,
|
||||
RecipeUpdate,
|
||||
@@ -52,6 +53,18 @@ async def create_recipe(
|
||||
return _recipe_to_response(recipe)
|
||||
|
||||
|
||||
@router.post("/{recipe_id}/duplicate", response_model=RecipeResponse, status_code=201)
|
||||
async def duplicate_recipe(
|
||||
recipe_id: int,
|
||||
data: RecipeDuplicate,
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Duplicate a recipe with its current tasks under a new code (#52). Requires Maker role."""
|
||||
recipe = await recipe_service.duplicate_recipe(db, recipe_id, data.code, data.name, user)
|
||||
return _recipe_to_response(recipe)
|
||||
|
||||
|
||||
@router.get("", response_model=RecipeListResponse)
|
||||
async def list_recipes(
|
||||
page: int = Query(1, ge=1),
|
||||
|
||||
@@ -23,6 +23,7 @@ async def download_spc_report(
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
@@ -32,6 +33,7 @@ async def download_spc_report(
|
||||
db, recipe_id, subtask_id,
|
||||
version_id, date_from, date_to,
|
||||
operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
@@ -57,6 +59,7 @@ async def download_measurement_report(
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
@@ -66,6 +69,7 @@ async def download_measurement_report(
|
||||
db, recipe_id, subtask_id,
|
||||
version_id, date_from, date_to,
|
||||
operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""System settings router - read/update settings, upload company logo."""
|
||||
import json
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -18,6 +21,23 @@ _VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
|
||||
_VALID_CSV_DECIMAL_SEPARATORS = {",", "."}
|
||||
|
||||
|
||||
def _validate_string_list(key: str, value: str) -> None:
|
||||
"""#49: array JSON di stringhe non vuote, max 50 voci (vuoto = nessuna lista)."""
|
||||
try:
|
||||
items = json.loads(value)
|
||||
except ValueError:
|
||||
items = None
|
||||
if (
|
||||
not isinstance(items, list)
|
||||
or len(items) > 50
|
||||
or not all(isinstance(i, str) and i.strip() for i in items)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{key} deve essere vuoto o un array JSON di al massimo 50 stringhe non vuote",
|
||||
)
|
||||
|
||||
|
||||
def _validate_setting_value(key: str, value: str) -> None:
|
||||
"""Raise 422 for a value that would break the code reading this key.
|
||||
|
||||
@@ -41,6 +61,29 @@ def _validate_setting_value(key: str, value: str) -> None:
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="auto_logout_minutes deve essere vuoto (disabilitato) o un intero fra 1 e 480",
|
||||
)
|
||||
elif key == "default_measurement_interval_minutes":
|
||||
# #47: vuoto = nessun default
|
||||
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 1440)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="default_measurement_interval_minutes deve essere vuoto o un intero fra 1 e 1440",
|
||||
)
|
||||
elif key in ("general_tolerance_plus", "general_tolerance_minus"):
|
||||
# #48: decimale >= 0 con punto; vuoto = nessun default
|
||||
if value != "":
|
||||
try:
|
||||
number = Decimal(value)
|
||||
ok = number.is_finite() and number >= 0
|
||||
except InvalidOperation:
|
||||
ok = False
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{key} deve essere vuoto o un numero decimale maggiore o uguale a 0 (punto decimale)",
|
||||
)
|
||||
elif key in ("list_measurement_types", "list_units"):
|
||||
if value != "":
|
||||
_validate_string_list(key, value)
|
||||
elif key in ("company_name", "company_logo_path"):
|
||||
if not value.strip() or len(value) > 200:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
|
||||
NOT protected by API Key auth (standalone access).
|
||||
"""
|
||||
import asyncio
|
||||
import hmac
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
@@ -312,7 +313,9 @@ async def init_db(body: PasswordBody):
|
||||
await _require_not_initialised()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
_stamp_head()
|
||||
# #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
|
||||
# in un thread non c'e' un loop attivo e funziona.
|
||||
await asyncio.to_thread(_stamp_head)
|
||||
return {"status": "ok", "message": "Database tables created successfully"}
|
||||
|
||||
|
||||
@@ -748,6 +751,8 @@ async def reset_db(body: ResetDbBody):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
_stamp_head()
|
||||
# #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
|
||||
# in un thread non c'e' un loop attivo e funziona.
|
||||
await asyncio.to_thread(_stamp_head)
|
||||
|
||||
return {"status": "ok", "message": "Database reset successfully"}
|
||||
|
||||
@@ -67,6 +67,21 @@ async def create_new_station(
|
||||
# The int-typed station_id param already guards against "by-code" being
|
||||
# matched as a station id, but keeping the explicit order avoids surprises
|
||||
# during refactors (e.g. if someone regroups handlers by HTTP method).
|
||||
@router.get("/by-code/{code}", response_model=StationResponse)
|
||||
async def get_station_by_code(
|
||||
code: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a station by its code (any authenticated user, like /by-code/{code}/recipes) (#72)."""
|
||||
station = await station_service.get_station_by_code(db, code)
|
||||
if station is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Station not found",
|
||||
)
|
||||
return StationResponse.model_validate(station)
|
||||
|
||||
|
||||
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
|
||||
async def list_recipes_by_station_code(
|
||||
code: str,
|
||||
|
||||
@@ -3,14 +3,16 @@ from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcNaiveInput
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_metrologist
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.statistics import (
|
||||
CapabilityData,
|
||||
@@ -38,6 +40,7 @@ async def _query_measurements(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58: solo le misure di una sessione
|
||||
) -> list[Measurement]:
|
||||
"""Query measurements with common filters.
|
||||
|
||||
@@ -67,6 +70,9 @@ async def _query_measurements(
|
||||
if serial_number is not None:
|
||||
filters.append(Measurement.serial_number == serial_number)
|
||||
|
||||
if production_run_id is not None:
|
||||
filters.append(Measurement.production_run_id == production_run_id)
|
||||
|
||||
query = (
|
||||
select(Measurement)
|
||||
.where(and_(*filters) if filters else True)
|
||||
@@ -106,6 +112,7 @@ async def get_summary(
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SummaryData:
|
||||
@@ -113,6 +120,7 @@ async def get_summary(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
pass_fail_values = [m.pass_fail for m in measurements]
|
||||
return compute_summary(pass_fail_values)
|
||||
@@ -128,6 +136,7 @@ async def get_capability(
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CapabilityData:
|
||||
@@ -135,6 +144,7 @@ async def get_capability(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||
@@ -151,6 +161,7 @@ async def get_control_chart(
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ControlChartData:
|
||||
@@ -158,6 +169,7 @@ async def get_control_chart(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
timestamps = [m.measured_at for m in measurements]
|
||||
@@ -178,6 +190,7 @@ async def get_histogram(
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
n_bins: int = Query(20, ge=5, le=100),
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -186,6 +199,7 @@ async def get_histogram(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
return compute_histogram(values, n_bins)
|
||||
@@ -235,3 +249,122 @@ async def get_recipe_subtasks(
|
||||
})
|
||||
|
||||
return subtasks_list
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def get_recipe_runs(
|
||||
recipe_id: int = Query(...),
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""#58: production runs of a recipe (for the "Sessione" filter), newest first."""
|
||||
runs = (await db.execute(
|
||||
select(ProductionRun)
|
||||
.where(ProductionRun.recipe_id == recipe_id)
|
||||
.order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc())
|
||||
)).scalars().all()
|
||||
# #58: conta solo le misure dei run della ricetta, non tutta la tabella.
|
||||
run_ids = [r.id for r in runs]
|
||||
counts = {}
|
||||
if run_ids:
|
||||
counts = dict((await db.execute(
|
||||
select(Measurement.production_run_id, func.count(Measurement.id))
|
||||
.where(Measurement.production_run_id.in_(run_ids))
|
||||
.group_by(Measurement.production_run_id)
|
||||
)).all())
|
||||
operator_ids = {r.operator_id for r in runs}
|
||||
names = {}
|
||||
if operator_ids:
|
||||
names = dict((await db.execute(
|
||||
select(User.id, User.display_name).where(User.id.in_(operator_ids))
|
||||
)).all())
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"station_id": r.station_id,
|
||||
"station_name": r.station.name if r.station else None,
|
||||
"operator_id": r.operator_id,
|
||||
"operator_name": names.get(r.operator_id),
|
||||
"started_at": r.started_at.isoformat() if r.started_at else None,
|
||||
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
||||
"status": r.status,
|
||||
"lot_number": r.lot_number,
|
||||
"serial_number": r.serial_number,
|
||||
"cycle_count": r.cycle_count,
|
||||
"measurement_count": counts.get(r.id, 0),
|
||||
}
|
||||
for r in runs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/task-results")
|
||||
async def get_run_task_results(
|
||||
run_id: int,
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""#58: esiti, note, campi e allegati dei task di una sessione di produzione.
|
||||
|
||||
Endpoint di statistica (require_metrologist): GET /api/task-results vuole
|
||||
il ruolo MeasurementTec e non si puo' riusare qui.
|
||||
"""
|
||||
run = (await db.execute(
|
||||
select(ProductionRun.id).where(ProductionRun.id == run_id)
|
||||
)).scalar_one_or_none()
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Sessione non trovata")
|
||||
|
||||
# #58: una query per i risultati della run, una per i task, una per gli utenti.
|
||||
results = (await db.execute(
|
||||
select(TaskResult)
|
||||
.where(TaskResult.production_run_id == run_id)
|
||||
.order_by(TaskResult.created_at.asc(), TaskResult.id.asc())
|
||||
)).scalars().all()
|
||||
|
||||
task_ids = {r.task_id for r in results}
|
||||
tasks = {}
|
||||
if task_ids:
|
||||
tasks = {t.id: t for t in (await db.execute(
|
||||
select(RecipeTask).where(RecipeTask.id.in_(task_ids))
|
||||
)).scalars().all()}
|
||||
user_ids = {r.user_id for r in results}
|
||||
names = {}
|
||||
if user_ids:
|
||||
names = dict((await db.execute(
|
||||
select(User.id, User.display_name).where(User.id.in_(user_ids))
|
||||
)).all())
|
||||
|
||||
counts = {"ok": 0, "nok": 0, "done": 0}
|
||||
out = []
|
||||
for r in results:
|
||||
counts[r.outcome] = counts.get(r.outcome, 0) + 1
|
||||
task = tasks.get(r.task_id)
|
||||
# #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave.
|
||||
labels = {
|
||||
f.get("key"): f.get("label") or f.get("key")
|
||||
for f in (task.fields or []) if isinstance(f, dict)
|
||||
} if task else {}
|
||||
out.append({
|
||||
"id": r.id,
|
||||
"task_id": r.task_id,
|
||||
"task_title": task.title if task else None,
|
||||
"task_type": task.task_type if task else None,
|
||||
"outcome": r.outcome,
|
||||
"note": r.note,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"user_name": names.get(r.user_id),
|
||||
"values": [
|
||||
{"key": k, "label": labels.get(k, k), "value": v}
|
||||
for k, v in (r.values or {}).items()
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"key": a.get("key"),
|
||||
"label": labels.get(a.get("key"), a.get("key")),
|
||||
"name": a.get("name"),
|
||||
"path": a.get("path"),
|
||||
}
|
||||
for a in (r.attachments or []) if isinstance(a, dict)
|
||||
],
|
||||
})
|
||||
return {"run_id": run_id, "counts": counts, "results": out}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Task results router: l'esito di un task 'check' (OK/NOK) o 'note' (#54 #55).
|
||||
|
||||
Un 'check' registra ok o nok (nok vuole la nota); un 'note' registra 'done' con i
|
||||
valori dei campi extra e gli allegati. I task di misura non passano di qui: le
|
||||
loro letture sono Measurement.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.api.middleware.api_key import require_measurement_tec
|
||||
from src.backend.api.routers.files import (
|
||||
_UPLOAD_CHUNK_SIZE, detect_file_type, sanitize_filename,
|
||||
)
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.models.api.task_result import TaskResultCreate, TaskResultResponse
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
from src.backend.models.orm.task_result import TaskResult
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/task-results", tags=["task-results"])
|
||||
|
||||
|
||||
def _unprocessable(detail: str) -> HTTPException:
|
||||
# 422 con detail testuale: l'api_client del frontend legge solo `detail`
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
|
||||
|
||||
|
||||
# Radice degli allegati degli esiti: sotto upload_path, mai cancellabile (audit).
|
||||
ATTACHMENT_DIR = "task_results"
|
||||
|
||||
|
||||
def _safe_relative_path(path: str) -> bool:
|
||||
"""Allegato: relativo, dentro task_results/, senza '..', unita' o backslash."""
|
||||
if path.startswith("/") or "\\" in path or ":" in path:
|
||||
return False
|
||||
parts = path.split("/")
|
||||
return ".." not in parts and parts[0] == ATTACHMENT_DIR and len(parts) > 1
|
||||
|
||||
|
||||
@router.post("/attachments", status_code=status.HTTP_201_CREATED)
|
||||
async def upload_attachment(
|
||||
file: UploadFile = File(...),
|
||||
_user: User = Depends(require_measurement_tec),
|
||||
):
|
||||
"""Carica l'allegato di un task 'note' (l'operatore non ha accesso a /api/files/upload).
|
||||
|
||||
Stessi controlli di upload_file: tipo dai magic bytes, limite di dimensione
|
||||
con lettura a blocchi, nome ripulito. Salva sotto task_results/<AAAA>/<MM>/.
|
||||
"""
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
buffer = bytearray()
|
||||
while True:
|
||||
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
)
|
||||
content = bytes(buffer)
|
||||
|
||||
detected = detect_file_type(content)
|
||||
if detected is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File type not allowed. Must be a recognised image or PDF.",
|
||||
)
|
||||
_mime, ext = detected
|
||||
|
||||
# nome originale ripulito (per mostrarlo); sul disco solo uuid + estensione rilevata
|
||||
name = sanitize_filename(file.filename or "")
|
||||
now = datetime.now(timezone.utc)
|
||||
storage_dir = settings.upload_path / ATTACHMENT_DIR / f"{now:%Y}" / f"{now:%m}"
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = storage_dir / f"{uuid.uuid4().hex}{ext}"
|
||||
target.write_bytes(content)
|
||||
|
||||
return {
|
||||
"file_path": str(target.relative_to(settings.upload_path)).replace("\\", "/"),
|
||||
"name": name,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=TaskResultResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task_result(
|
||||
data: TaskResultCreate,
|
||||
user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Registra l'esito di un task 'check' o 'note'."""
|
||||
task = (await db.execute(
|
||||
select(RecipeTask).where(RecipeTask.id == data.task_id)
|
||||
)).scalar_one_or_none()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
||||
|
||||
note = data.note.strip() if data.note else None
|
||||
note = note or None
|
||||
values: Optional[dict] = None
|
||||
attachments: Optional[list] = None
|
||||
|
||||
if task.task_type == "check":
|
||||
if data.outcome not in ("ok", "nok"):
|
||||
raise _unprocessable("Un controllo si registra come ok o nok")
|
||||
if data.outcome == "nok" and not note:
|
||||
raise _unprocessable("Per un NOK serve la nota")
|
||||
elif task.task_type == "note":
|
||||
if data.outcome != "done":
|
||||
raise _unprocessable("Una nota si registra come done")
|
||||
fields = {f["key"]: f for f in (task.fields or [])}
|
||||
values = {}
|
||||
for key, value in (data.values or {}).items():
|
||||
if key not in fields:
|
||||
raise _unprocessable(f"Campo '{key}' non previsto dal task")
|
||||
if fields[key]["type"] == "number" and value.strip() != "":
|
||||
try:
|
||||
float(value.replace(",", "."))
|
||||
except ValueError:
|
||||
raise _unprocessable(f"Il campo '{fields[key]['label']}' deve essere un numero")
|
||||
values[key] = value
|
||||
attachments = []
|
||||
for att in data.attachments or []:
|
||||
if not _safe_relative_path(att.path):
|
||||
raise _unprocessable(
|
||||
f"Percorso allegato non valido (deve stare in {ATTACHMENT_DIR}/): {att.path}"
|
||||
)
|
||||
if att.key is not None and att.key not in fields:
|
||||
raise _unprocessable(f"Campo allegato '{att.key}' non previsto dal task")
|
||||
attachments.append(att.model_dump())
|
||||
values = values or None
|
||||
attachments = attachments or None
|
||||
else:
|
||||
raise _unprocessable(
|
||||
f"I task di tipo '{task.task_type}' non registrano un esito"
|
||||
)
|
||||
|
||||
if data.production_run_id is not None:
|
||||
run = (await db.execute(
|
||||
select(ProductionRun).where(ProductionRun.id == data.production_run_id)
|
||||
)).scalar_one_or_none()
|
||||
if run is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found"
|
||||
)
|
||||
if run.status == "closed":
|
||||
raise _unprocessable("La produzione e' gia' chiusa")
|
||||
if run.version_id != task.version_id:
|
||||
raise _unprocessable("Il task non appartiene alla versione di questa produzione")
|
||||
|
||||
result = TaskResult(
|
||||
task_id=task.id,
|
||||
version_id=task.version_id, # presa dal task, non dal client
|
||||
production_run_id=data.production_run_id,
|
||||
user_id=user.id,
|
||||
outcome=data.outcome,
|
||||
note=note,
|
||||
values=values,
|
||||
attachments=attachments,
|
||||
)
|
||||
db.add(result)
|
||||
await db.flush()
|
||||
await db.refresh(result) # created_at e' un server_default
|
||||
return TaskResultResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskResultResponse])
|
||||
async def list_task_results(
|
||||
production_run_id: Optional[int] = Query(None, gt=0),
|
||||
task_id: Optional[int] = Query(None, gt=0),
|
||||
_user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Esiti registrati, filtrabili per produzione e/o task; il piu' recente prima."""
|
||||
stmt = select(TaskResult)
|
||||
if production_run_id is not None:
|
||||
stmt = stmt.where(TaskResult.production_run_id == production_run_id)
|
||||
if task_id is not None:
|
||||
stmt = stmt.where(TaskResult.task_id == task_id)
|
||||
rows = (await db.execute(
|
||||
stmt.order_by(TaskResult.created_at.desc(), TaskResult.id.desc())
|
||||
)).scalars().all()
|
||||
return [TaskResultResponse.model_validate(r) for r in rows]
|
||||
@@ -17,6 +17,9 @@ from src.backend.models.api.task import (
|
||||
TaskReorderRequest,
|
||||
TaskResponse,
|
||||
TaskUpdate,
|
||||
check_task_loop_options,
|
||||
default_in_loop,
|
||||
normalize_note_fields,
|
||||
validate_tolerance_order,
|
||||
)
|
||||
from src.backend.models.orm.vision import VisionReferenceImage
|
||||
@@ -202,6 +205,13 @@ async def create_task(
|
||||
This creates a NEW version via copy-on-write, then appends the task to
|
||||
the new version. Requires Maker role.
|
||||
"""
|
||||
# #53 #55: validazione di in_loop/at_close/fields prima di toccare il db
|
||||
try:
|
||||
fields = normalize_note_fields(data.fields)
|
||||
check_task_loop_options(data.task_type, data.at_close, fields)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
# Ensure recipe exists and is active
|
||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||
recipe = result.scalar_one_or_none()
|
||||
@@ -240,6 +250,10 @@ async def create_task(
|
||||
file_type=data.file_type,
|
||||
annotations_json=data.annotations_json,
|
||||
vision_json=data.vision_json,
|
||||
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
|
||||
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
|
||||
at_close=data.at_close,
|
||||
fields=fields,
|
||||
)
|
||||
db.add(new_task)
|
||||
await db.flush()
|
||||
@@ -341,6 +355,26 @@ async def update_task(
|
||||
if not update_data:
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
# #53 #55: i tre campi nuovi si validano sulla combinazione finale (valori
|
||||
# nuovi, o quelli gia' sul task per cio' che il client non manda)
|
||||
if "fields" in update_data:
|
||||
try:
|
||||
update_data["fields"] = normalize_note_fields(update_data["fields"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
for flag in ("in_loop", "at_close"):
|
||||
if flag in update_data and update_data[flag] is None:
|
||||
del update_data[flag] # i booleani non sono nullable: null = non toccare
|
||||
final_type = update_data.get("task_type") or task.task_type
|
||||
try:
|
||||
check_task_loop_options(
|
||||
final_type,
|
||||
update_data.get("at_close", task.at_close),
|
||||
update_data.get("fields", task.fields),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
task = await _task_for_write(db, task, user)
|
||||
|
||||
for field, value in update_data.items():
|
||||
|
||||
@@ -6,8 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_admin_user
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key, revoke_all_keys
|
||||
from src.backend.models.api.user import PinSet, UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||
from src.backend.services.auth_service import (
|
||||
create_user,
|
||||
hash_password,
|
||||
regenerate_api_key,
|
||||
revoke_all_keys,
|
||||
set_user_pin,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
@@ -151,6 +157,30 @@ async def change_user_password(
|
||||
return {"message": f"Password changed for user {user.username}"}
|
||||
|
||||
|
||||
@router.put("/{user_id}/pin", status_code=status.HTTP_200_OK)
|
||||
async def change_user_pin(
|
||||
user_id: int,
|
||||
data: PinSet,
|
||||
admin: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Imposta o toglie (pin null) il PIN di un utente (admin, #59/#51).
|
||||
|
||||
422 se l'utente e' admin. Il PIN non si restituisce mai.
|
||||
"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
if user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Admin users cannot have a PIN",
|
||||
)
|
||||
await set_user_pin(db, user, data.pin)
|
||||
return {"message": f"PIN {'set' if data.pin else 'removed'} for user {user.username}"}
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def deactivate_user(
|
||||
user_id: int,
|
||||
|
||||
@@ -5,19 +5,25 @@ the numbers: that is what lets a station's configuration change without touching
|
||||
the frontend or the recipes.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
|
||||
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, validate_file_size
|
||||
from src.backend.api.routers.files import (
|
||||
ALLOWED_IMAGE_TYPES, resolve_upload_path, validate_file_size,
|
||||
)
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.models.api.vision import (
|
||||
VisionExecuteResponse, VisionMeasurementResult, VisionReferenceImageResponse,
|
||||
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
||||
VisionRecipeUploadResponse, VisionReferenceImageResponse,
|
||||
)
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import vision_service
|
||||
@@ -126,7 +132,7 @@ async def execute(
|
||||
) -> VisionExecuteResponse:
|
||||
task = await _task_or_404(db, task_id)
|
||||
|
||||
result, saved = await vision_service.execute_task(
|
||||
result, saved, report = await vision_service.execute_task(
|
||||
db,
|
||||
task,
|
||||
await _read_image(image),
|
||||
@@ -135,6 +141,7 @@ async def execute(
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
production_run_id=production_run_id,
|
||||
image_ext=_IMAGE_EXTENSIONS.get(image.content_type, "png"),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -147,6 +154,111 @@ async def execute(
|
||||
)
|
||||
for m in saved
|
||||
],
|
||||
outcome=report.get("outcome"),
|
||||
overlay=report.get("overlay") or [],
|
||||
image_url=f"/api/vision/results/{result.id}/image",
|
||||
failures=report.get("failures") or [],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/results/{result_id}/image")
|
||||
async def get_result_image(
|
||||
result_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_measurement_tec),
|
||||
) -> FileResponse:
|
||||
"""The image a vision run measured, as saved by `execute` (#46)."""
|
||||
result = (await db.execute(
|
||||
select(VisionResult).where(VisionResult.id == result_id)
|
||||
)).scalar_one_or_none()
|
||||
if result is None or not result.image_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Result image not found",
|
||||
)
|
||||
return FileResponse(resolve_upload_path(result.image_path))
|
||||
|
||||
|
||||
def _422(errors: list[str]) -> HTTPException:
|
||||
# detail e' una lista di stringhe: il frontend legge i 422 solo da `detail`.
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=errors,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
||||
async def upload_recipe(
|
||||
task_id: int,
|
||||
recipe: UploadFile = File(...),
|
||||
files: list[UploadFile] = File(default=[]),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> VisionRecipeUploadResponse:
|
||||
"""Load a VisionSuite v2 recipe plus its meta files onto a task (#46)."""
|
||||
task = await _task_or_404(db, task_id)
|
||||
if task.task_type not in vision_service.VISION_TASK_TYPES:
|
||||
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
|
||||
|
||||
raw = await recipe.read()
|
||||
if not validate_file_size(len(raw)):
|
||||
raise _422([f"recipe exceeds {settings.max_upload_size_mb}MB"])
|
||||
try:
|
||||
graph = json.loads(raw)
|
||||
except ValueError:
|
||||
raise _422(["the recipe is not valid JSON"])
|
||||
if not isinstance(graph, dict):
|
||||
raise _422(["the recipe must be a JSON object"])
|
||||
|
||||
uploads: list[tuple[str, bytes]] = []
|
||||
for f in files:
|
||||
content = await f.read()
|
||||
if not validate_file_size(len(content)):
|
||||
raise _422([f"file {f.filename!r} exceeds {settings.max_upload_size_mb}MB"])
|
||||
uploads.append((f.filename or "", content))
|
||||
placed, errors = vision_service.match_recipe_files(graph, uploads)
|
||||
if errors:
|
||||
raise _422(errors)
|
||||
|
||||
# The folder is content-addressed and immutable; writing it before the
|
||||
# validation is harmless (an orphan folder is never referenced) and the
|
||||
# worker needs it on disk to validate meta files.
|
||||
try:
|
||||
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
||||
except ValueError as exc:
|
||||
raise _422([str(exc)])
|
||||
check = await vision_service.validate_recipe(graph, recipe_dir)
|
||||
if not check["ok"]:
|
||||
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
||||
|
||||
task.vision_json = graph
|
||||
task.vision_recipe_dir = recipe_dir
|
||||
await db.commit()
|
||||
return VisionRecipeUploadResponse(
|
||||
task_id=task.id,
|
||||
recipe_dir=recipe_dir,
|
||||
files=vision_service.list_recipe_files(recipe_dir),
|
||||
outputs=check["outputs"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/recipe", response_model=VisionRecipeResponse)
|
||||
async def get_recipe(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> VisionRecipeResponse:
|
||||
task = await _task_or_404(db, task_id)
|
||||
outputs: list[dict] = []
|
||||
if task.vision_json:
|
||||
check = await vision_service.validate_recipe(
|
||||
task.vision_json, task.vision_recipe_dir,
|
||||
)
|
||||
outputs = check.get("outputs", [])
|
||||
return VisionRecipeResponse(
|
||||
task_id=task.id,
|
||||
recipe_dir=task.vision_recipe_dir,
|
||||
recipe=task.vision_json,
|
||||
files=vision_service.list_recipe_files(task.vision_recipe_dir),
|
||||
outputs=outputs,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+17
-2
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
|
||||
db_name: str = "tiemeasureflow"
|
||||
db_user: str = "tmflow"
|
||||
db_password: str = _DEFAULT_DB_PASSWORD
|
||||
# Full SQLAlchemy async URL (env DB_URL). When set it wins over the
|
||||
# DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD parts. Used by the Windows
|
||||
# package: sqlite+aiosqlite:///C:/ProgramData/.../tmf.db (forward slashes).
|
||||
db_url: str | None = None
|
||||
|
||||
# Presentation time zone (DB is UTC, #42)
|
||||
display_timezone: str = "Europe/Rome"
|
||||
@@ -78,7 +82,9 @@ class Settings(BaseSettings):
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Async MySQL connection string."""
|
||||
"""Async connection string: DB_URL if set, else the MySQL one."""
|
||||
if self.db_url:
|
||||
return self.db_url
|
||||
return (
|
||||
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||
@@ -97,7 +103,12 @@ class Settings(BaseSettings):
|
||||
(mounted as a Docker volume), not inside the backend tree.
|
||||
"""
|
||||
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
||||
return Path(__file__).resolve().parents[2] / self.upload_dir
|
||||
# UPLOAD_DIR may be absolute (Windows package: C:/ProgramData/...):
|
||||
# in that case it is used as is instead of being joined to the root.
|
||||
configured = Path(self.upload_dir)
|
||||
if configured.is_absolute():
|
||||
return configured
|
||||
return Path(__file__).resolve().parents[2] / configured
|
||||
|
||||
# Always resolve .env against the project root regardless of cwd
|
||||
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
||||
@@ -122,6 +133,10 @@ class Settings(BaseSettings):
|
||||
"SERVER_SECRET_KEY is still the committed default value; "
|
||||
"set it before running with APP_ENV=" + self.app_env
|
||||
)
|
||||
# A SQLite file has no DB password: the default-password guard only
|
||||
# makes sense for the MySQL connection built from the DB_* parts.
|
||||
if self.db_url and self.db_url.startswith("sqlite"):
|
||||
return self
|
||||
if self.db_password == _DEFAULT_DB_PASSWORD:
|
||||
raise RuntimeError(
|
||||
"DB_PASSWORD is still the committed default value; "
|
||||
|
||||
+25
-8
@@ -1,6 +1,7 @@
|
||||
"""Async SQLAlchemy database engine and session management."""
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
@@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from src.backend.config import settings
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
_IS_SQLITE = settings.database_url.startswith("sqlite")
|
||||
|
||||
# Create async engine. MySQL gets a connection pool; SQLite (local file of the
|
||||
# Windows package) does not accept pool_size/max_overflow/pool_recycle.
|
||||
if _IS_SQLITE:
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _sqlite_pragmas(dbapi_connection, _connection_record) -> None:
|
||||
"""Per-connection PRAGMAs: SQLite ignores FKs unless asked, and WAL +
|
||||
busy_timeout let the web, vision and backend threads share the file."""
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.close()
|
||||
else:
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
|
||||
# Session factory
|
||||
async_session_factory = async_sessionmaker(
|
||||
|
||||
@@ -23,6 +23,7 @@ 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
|
||||
from src.backend.api.routers.task_results import router as task_results_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -77,6 +78,7 @@ app.include_router(setup_router)
|
||||
app.include_router(stations_router)
|
||||
app.include_router(production_router)
|
||||
app.include_router(vision_router)
|
||||
app.include_router(task_results_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -22,24 +22,44 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||
),
|
||||
)
|
||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||
# Idempotent on purpose (#71): a database born from create_all has no
|
||||
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
|
||||
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
|
||||
# created and `alembic_version` still at 014. Every step checks first.
|
||||
insp = sa.inspect(op.get_bind())
|
||||
|
||||
if 'api_keys' not in insp.get_table_names():
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||
),
|
||||
)
|
||||
existing_idx: set[str] = set()
|
||||
else:
|
||||
existing_idx = {i['name'] for i in insp.get_indexes('api_keys')}
|
||||
|
||||
if 'ix_api_keys_key_hash' not in existing_idx:
|
||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||
if 'ix_api_keys_user_id' not in existing_idx:
|
||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||
|
||||
if 'api_key' not in {c['name'] for c in insp.get_columns('users')}:
|
||||
return # column already gone: nothing else to do
|
||||
|
||||
user_idx = {i['name'] for i in insp.get_indexes('users')}
|
||||
user_uq = {u['name'] for u in insp.get_unique_constraints('users')}
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.drop_index('ix_users_api_key')
|
||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||
if 'ix_users_api_key' in user_idx:
|
||||
batch.drop_index('ix_users_api_key')
|
||||
if 'uq_users_api_key' in user_uq:
|
||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||
batch.drop_column('api_key')
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46)
|
||||
|
||||
Percorso relativo sotto upload_path della cartella immutabile che contiene
|
||||
recipe.json e i file di meta (DXF, modelli .npz, ...).
|
||||
|
||||
Revision ID: 016_vision_recipe_dir
|
||||
Revises: 015_api_key_hash
|
||||
Create Date: 2026-10-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '016_vision_recipe_dir'
|
||||
down_revision: Union[str, None] = '015_api_key_hash'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.drop_column('vision_recipe_dir')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""users: PIN a 4 cifre per login rapido e capoturno (#59)
|
||||
|
||||
pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco.
|
||||
|
||||
Revision ID: 017_user_pin
|
||||
Revises: 016_vision_recipe_dir
|
||||
Create Date: 2026-10-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '017_user_pin'
|
||||
down_revision: Union[str, None] = '016_vision_recipe_dir'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True))
|
||||
batch.add_column(sa.Column(
|
||||
'pin_failed_count', sa.Integer(), nullable=False, server_default='0'))
|
||||
batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.drop_column('pin_locked_until')
|
||||
batch.drop_column('pin_failed_count')
|
||||
batch.drop_column('pin_hash')
|
||||
@@ -0,0 +1,108 @@
|
||||
"""task: in_loop / at_close / fields, tipo 'check', tabella task_results (#53 #54 #55)
|
||||
|
||||
in_loop: il task fa parte del ciclo di produzione (prima: implicito dal tipo).
|
||||
at_close: il task 'note' si compila alla fine produzione.
|
||||
fields: campi extra del task 'note' (JSON).
|
||||
task_results: esito registrato da un task 'check' (OK/NOK) o 'note' (done).
|
||||
|
||||
Revision ID: 018_task_loop_results
|
||||
Revises: 017_user_pin
|
||||
Create Date: 2026-10-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '018_task_loop_results'
|
||||
down_revision: Union[str, None] = '017_user_pin'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
OLD_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
||||
NEW_TYPES = OLD_TYPES + ('check',)
|
||||
# stessi tipi di MEASURING_TASK_TYPES: fino a oggi erano l'unico ciclo possibile
|
||||
LOOP_TYPES = ('measure', 'camera_measure', 'dxf_compare')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.add_column(sa.Column(
|
||||
'in_loop', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
batch.add_column(sa.Column(
|
||||
'at_close', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
batch.add_column(sa.Column('fields', sa.JSON(), nullable=True))
|
||||
# enum allargato come in 011: 'check' = Controllo OK/NOK
|
||||
batch.alter_column(
|
||||
'task_type',
|
||||
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||
existing_nullable=False,
|
||||
existing_server_default='note',
|
||||
)
|
||||
|
||||
# i task di misura erano gia' nel ciclo: lo restano
|
||||
recipe_tasks = sa.table(
|
||||
'recipe_tasks', sa.column('task_type', sa.String), sa.column('in_loop', sa.Boolean))
|
||||
op.execute(
|
||||
recipe_tasks.update()
|
||||
.where(recipe_tasks.c.task_type.in_(LOOP_TYPES))
|
||||
.values(in_loop=True)
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'task_results',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('task_id', sa.Integer(),
|
||||
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('version_id', sa.Integer(),
|
||||
sa.ForeignKey('recipe_versions.id'), nullable=False),
|
||||
sa.Column('production_run_id', sa.Integer(),
|
||||
sa.ForeignKey('production_runs.id', ondelete='SET NULL'), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('outcome', sa.Enum('ok', 'nok', 'done', name='task_outcome_enum'),
|
||||
nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=True),
|
||||
sa.Column('values', sa.JSON(), nullable=True),
|
||||
sa.Column('attachments', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
op.create_index('ix_task_results_task_id', 'task_results', ['task_id'])
|
||||
op.create_index('ix_task_results_version_id', 'task_results', ['version_id'])
|
||||
op.create_index(
|
||||
'ix_task_results_production_run_id', 'task_results', ['production_run_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# il tipo 'check' non esiste prima di 018: se un task lo usa, tornare indietro
|
||||
# lo lascerebbe con un valore fuori enum
|
||||
bind = op.get_bind()
|
||||
used = bind.execute(
|
||||
sa.text("SELECT COUNT(*) FROM recipe_tasks WHERE task_type = 'check'")
|
||||
).scalar()
|
||||
if used:
|
||||
raise RuntimeError(
|
||||
f"Downgrade 018 impossibile: {used} task usano il tipo 'check'. "
|
||||
"Cambiare il tipo o eliminare quei task prima di tornare indietro."
|
||||
)
|
||||
|
||||
op.drop_index('ix_task_results_production_run_id', table_name='task_results')
|
||||
op.drop_index('ix_task_results_version_id', table_name='task_results')
|
||||
op.drop_index('ix_task_results_task_id', table_name='task_results')
|
||||
op.drop_table('task_results')
|
||||
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.alter_column(
|
||||
'task_type',
|
||||
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||
existing_nullable=False,
|
||||
existing_server_default='note',
|
||||
)
|
||||
batch.drop_column('fields')
|
||||
batch.drop_column('at_close')
|
||||
batch.drop_column('in_loop')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""stations.vision_worker_url e vision_results.worker_url (#45)
|
||||
|
||||
stations.vision_worker_url: worker di visione della stazione (NULL = centrale).
|
||||
vision_results.worker_url: worker effettivamente chiamato per quel risultato.
|
||||
|
||||
Revision ID: 019_station_vision_worker
|
||||
Revises: 018_task_loop_results
|
||||
Create Date: 2026-10-11
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '019_station_vision_worker'
|
||||
down_revision: Union[str, None] = '018_task_loop_results'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.add_column(sa.Column('vision_worker_url', sa.String(500), nullable=True))
|
||||
with op.batch_alter_table('vision_results') as batch:
|
||||
batch.add_column(sa.Column('worker_url', sa.String(500), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('vision_results') as batch:
|
||||
batch.drop_column('worker_url')
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.drop_column('vision_worker_url')
|
||||
@@ -0,0 +1,33 @@
|
||||
"""stations.camera_url e stations.camera_source (#72)
|
||||
|
||||
stations.camera_url: servizio camera della stazione (vs-gui o equivalente).
|
||||
stations.camera_source: sorgente da chiedere al servizio, es. camera:<seriale>
|
||||
o camera:folder.
|
||||
|
||||
Revision ID: 020_station_camera
|
||||
Revises: 019_station_vision_worker
|
||||
Create Date: 2026-10-15
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '020_station_camera'
|
||||
down_revision: Union[str, None] = '019_station_vision_worker'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.add_column(sa.Column('camera_url', sa.String(500), nullable=True))
|
||||
batch.add_column(sa.Column('camera_source', sa.String(200), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.drop_column('camera_source')
|
||||
batch.drop_column('camera_url')
|
||||
@@ -2,7 +2,7 @@
|
||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class MeasurementCreate(BaseModel):
|
||||
@@ -71,8 +71,21 @@ class MeasurementListResponse(BaseModel):
|
||||
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)
|
||||
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||
supervisor_username: Optional[str] = Field(None, min_length=1)
|
||||
supervisor_password: Optional[str] = Field(None, min_length=1)
|
||||
supervisor_user_id: Optional[int] = Field(None, gt=0)
|
||||
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_credential_pair(self):
|
||||
by_password = self.supervisor_username and self.supervisor_password
|
||||
by_pin = self.supervisor_user_id and self.supervisor_pin
|
||||
if not (by_password or by_pin):
|
||||
raise ValueError(
|
||||
"supervisor credentials required: username+password or user_id+pin"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class TaskProgressResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""API models for the live station monitor (#57)."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MonitorSubtask(BaseModel):
|
||||
"""Mean of the recent measurements of one quote, against its tolerance."""
|
||||
|
||||
subtask_id: int
|
||||
description: str
|
||||
unit: str
|
||||
count: int
|
||||
mean: float
|
||||
ltl: Optional[float] = None
|
||||
utl: Optional[float] = None
|
||||
# True when ltl <= mean <= utl (a missing limit does not constrain).
|
||||
in_tolerance: bool
|
||||
|
||||
|
||||
class MonitorEvent(BaseModel):
|
||||
event_type: str
|
||||
created_at: datetime
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class MonitorRun(BaseModel):
|
||||
id: int
|
||||
station_code: str
|
||||
station_name: str
|
||||
recipe_code: str
|
||||
recipe_name: str
|
||||
version_number: int
|
||||
operator: str
|
||||
status: str
|
||||
started_at: datetime
|
||||
duration_seconds: int
|
||||
cycle_count: int
|
||||
seconds_to_next_measurement: Optional[int] = None
|
||||
overdue: bool
|
||||
last_event: Optional[MonitorEvent] = None
|
||||
# Counts over the last MONITOR_WINDOW measurements of the run.
|
||||
measured: int
|
||||
pass_count: int
|
||||
warning_count: int
|
||||
fail_count: int
|
||||
subtasks: list[MonitorSubtask]
|
||||
server_time: datetime
|
||||
@@ -2,7 +2,7 @@
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class ProductionRunCreate(BaseModel):
|
||||
@@ -55,6 +55,8 @@ class ProductionRunResponse(BaseModel):
|
||||
# needs them to bring the operator back to the measurement when the interval
|
||||
# expires, whichever screen they are on, and to know which task closes a cycle.
|
||||
measurement_task_ids: list[int] = Field(default_factory=list)
|
||||
# #53: task 'note' con at_close, da compilare prima della modale del capoturno.
|
||||
close_task_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||
@@ -64,8 +66,21 @@ class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||
class SupervisorAction(BaseModel):
|
||||
"""Credentials of the supervisor authorising a line stop or a close."""
|
||||
|
||||
supervisor_username: str = Field(..., min_length=1)
|
||||
supervisor_password: str = Field(..., min_length=1)
|
||||
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||
supervisor_username: Optional[str] = Field(None, min_length=1)
|
||||
supervisor_password: Optional[str] = Field(None, min_length=1)
|
||||
supervisor_user_id: Optional[int] = Field(None, gt=0)
|
||||
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_credential_pair(self):
|
||||
by_password = self.supervisor_username and self.supervisor_password
|
||||
by_pin = self.supervisor_user_id and self.supervisor_pin
|
||||
if not (by_password or by_pin):
|
||||
raise ValueError(
|
||||
"supervisor credentials required: username+password or user_id+pin"
|
||||
)
|
||||
return self
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ class RecipeCreate(BaseModel):
|
||||
annotations_json: Optional[dict] = None
|
||||
|
||||
|
||||
class RecipeDuplicate(BaseModel):
|
||||
"""Schema for duplicating a recipe (#52): only the new code and name."""
|
||||
code: str = Field(..., min_length=1, max_length=100)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecipeUpdate(BaseModel):
|
||||
"""Schema for updating a recipe (creates new version)."""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
|
||||
@@ -2,7 +2,36 @@
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
def _normalize_http_url(value: Optional[str], field: str) -> Optional[str]:
|
||||
"""Vuoto/spazi -> None; altrimenti http(s) senza '/' finale."""
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
if not value.lower().startswith(("http://", "https://")):
|
||||
raise ValueError(f"{field} must start with http:// or https://")
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
|
||||
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
|
||||
return _normalize_http_url(value, "vision_worker_url")
|
||||
|
||||
|
||||
def _normalize_camera_url(value: Optional[str]) -> Optional[str]:
|
||||
"""Vuoto/spazi -> None (nessuna camera); altrimenti http(s) senza '/' finale (#72)."""
|
||||
return _normalize_http_url(value, "camera_url")
|
||||
|
||||
|
||||
def _normalize_camera_source(value: Optional[str]) -> Optional[str]:
|
||||
"""Strip; vuoto -> None (#72)."""
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip() or None
|
||||
|
||||
|
||||
class StationCreate(BaseModel):
|
||||
@@ -11,6 +40,15 @@ class StationCreate(BaseModel):
|
||||
location: Optional[str] = Field(default=None, max_length=255)
|
||||
notes: Optional[str] = None
|
||||
active: bool = True
|
||||
# worker di visione della stazione (#45); vuoto = centrale
|
||||
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||
# camera della stazione (#72); vuoto = nessuna camera
|
||||
camera_url: Optional[str] = Field(default=None, max_length=500)
|
||||
camera_source: Optional[str] = Field(default=None, max_length=200)
|
||||
|
||||
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
|
||||
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
|
||||
|
||||
|
||||
class StationUpdate(BaseModel):
|
||||
@@ -18,6 +56,15 @@ class StationUpdate(BaseModel):
|
||||
location: Optional[str] = Field(default=None, max_length=255)
|
||||
notes: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
# worker di visione della stazione (#45); "" -> None riporta al centrale
|
||||
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||
# camera della stazione (#72); vuoto = nessuna camera
|
||||
camera_url: Optional[str] = Field(default=None, max_length=500)
|
||||
camera_source: Optional[str] = Field(default=None, max_length=200)
|
||||
|
||||
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
|
||||
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
|
||||
|
||||
|
||||
class StationResponse(BaseModel):
|
||||
@@ -28,6 +75,9 @@ class StationResponse(BaseModel):
|
||||
location: Optional[str]
|
||||
notes: Optional[str]
|
||||
active: bool
|
||||
vision_worker_url: Optional[str] = None # #45
|
||||
camera_url: Optional[str] = None # #72
|
||||
camera_source: Optional[str] = None # #72
|
||||
created_by: int
|
||||
created_at: UtcDateTime
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
||||
import re
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from src.backend.models.orm.task import TASK_TYPES
|
||||
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
|
||||
|
||||
# Kept in step with the ORM enum so the two cannot drift apart.
|
||||
TaskType = Literal[TASK_TYPES]
|
||||
@@ -34,6 +35,76 @@ def validate_tolerance_order(
|
||||
)
|
||||
|
||||
|
||||
# #55: campi extra di un task 'note'
|
||||
MAX_NOTE_FIELDS = 20
|
||||
_FIELD_KEY_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||
NOTE_FIELD_TYPES = ("text", "number")
|
||||
|
||||
|
||||
def normalize_note_fields(fields: Optional[list]) -> Optional[list[dict]]:
|
||||
"""Valida e normalizza `fields` di un task 'note' (#55); lista vuota -> None.
|
||||
|
||||
Solleva ValueError con un messaggio leggibile (il router lo trasforma in 422).
|
||||
"""
|
||||
if not fields:
|
||||
return None
|
||||
if len(fields) > MAX_NOTE_FIELDS:
|
||||
raise ValueError(f"Al massimo {MAX_NOTE_FIELDS} campi per task")
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for i, raw in enumerate(fields, start=1):
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Campo {i}: formato non valido")
|
||||
key = raw.get("key")
|
||||
if not isinstance(key, str) or not _FIELD_KEY_RE.match(key):
|
||||
raise ValueError(
|
||||
f"Campo {i}: chiave non valida (solo a-z, 0-9, _, da 1 a 40 caratteri)"
|
||||
)
|
||||
if key in seen:
|
||||
raise ValueError(f"Campo {i}: chiave '{key}' duplicata")
|
||||
seen.add(key)
|
||||
label = raw.get("label")
|
||||
label = label.strip() if isinstance(label, str) else ""
|
||||
if not 1 <= len(label) <= 100:
|
||||
raise ValueError(f"Campo '{key}': etichetta da 1 a 100 caratteri")
|
||||
ftype = raw.get("type")
|
||||
if ftype not in NOTE_FIELD_TYPES:
|
||||
raise ValueError(f"Campo '{key}': tipo deve essere text o number")
|
||||
default = raw.get("default")
|
||||
if default is not None:
|
||||
if not isinstance(default, str):
|
||||
raise ValueError(f"Campo '{key}': il valore predefinito deve essere testo")
|
||||
if default == "":
|
||||
default = None
|
||||
if default is not None and ftype == "number":
|
||||
try:
|
||||
float(default.replace(",", "."))
|
||||
except ValueError:
|
||||
raise ValueError(f"Campo '{key}': il valore predefinito non e' un numero")
|
||||
attachment = raw.get("attachment", False)
|
||||
if not isinstance(attachment, bool):
|
||||
raise ValueError(f"Campo '{key}': attachment deve essere true o false")
|
||||
out.append({
|
||||
"key": key, "label": label, "type": ftype,
|
||||
"default": default, "attachment": attachment,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def check_task_loop_options(task_type: str, at_close: bool, fields: Optional[list]) -> None:
|
||||
"""at_close e fields sono ammessi solo per i task 'note' (#53 #55)."""
|
||||
if task_type != "note":
|
||||
if at_close:
|
||||
raise ValueError("'Alla chiusura' e' ammesso solo per i task di tipo note")
|
||||
if fields:
|
||||
raise ValueError("I campi extra sono ammessi solo per i task di tipo note")
|
||||
|
||||
|
||||
def default_in_loop(task_type: str) -> bool:
|
||||
"""in_loop quando non dichiarato: misure e check si', gli altri no (#53)."""
|
||||
return task_type in DEFAULT_LOOP_TASK_TYPES
|
||||
|
||||
|
||||
class SubtaskCreate(BaseModel):
|
||||
"""Schema for creating a subtask."""
|
||||
marker_number: int = Field(..., ge=1)
|
||||
@@ -98,6 +169,10 @@ class TaskCreate(BaseModel):
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
# #53: assente = default dal tipo (misure e check nel ciclo)
|
||||
in_loop: Optional[bool] = None
|
||||
at_close: bool = False
|
||||
fields: Optional[list[dict[str, Any]]] = None
|
||||
subtasks: list[SubtaskCreate] = []
|
||||
|
||||
|
||||
@@ -111,6 +186,9 @@ class TaskUpdate(BaseModel):
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
in_loop: Optional[bool] = None
|
||||
at_close: Optional[bool] = None
|
||||
fields: Optional[list[dict[str, Any]]] = None
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
@@ -129,6 +207,9 @@ class TaskResponse(BaseModel):
|
||||
file_type: Optional[str] = None
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
in_loop: bool = False
|
||||
at_close: bool = False
|
||||
fields: Optional[list[dict[str, Any]]] = None
|
||||
subtasks: list[SubtaskResponse] = []
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Pydantic schemas for task results (#54 check OK/NOK, #55 note con campi)."""
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
|
||||
|
||||
class TaskResultAttachment(BaseModel):
|
||||
"""Un allegato: `path` e' il percorso restituito da /api/files/upload."""
|
||||
key: Optional[str] = Field(None, max_length=40)
|
||||
path: str = Field(..., min_length=1, max_length=500)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
class TaskResultCreate(BaseModel):
|
||||
task_id: int = Field(..., gt=0)
|
||||
production_run_id: Optional[int] = Field(None, gt=0)
|
||||
outcome: Literal["ok", "nok", "done"]
|
||||
note: Optional[str] = None
|
||||
values: Optional[dict[str, str]] = None
|
||||
attachments: Optional[list[TaskResultAttachment]] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class TaskResultResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
task_id: int
|
||||
version_id: int
|
||||
production_run_id: Optional[int] = None
|
||||
user_id: int
|
||||
outcome: str
|
||||
note: Optional[str] = None
|
||||
values: Optional[dict[str, str]] = None
|
||||
attachments: Optional[list[TaskResultAttachment]] = None
|
||||
created_at: UtcDateTime
|
||||
@@ -50,6 +50,8 @@ class UserResponse(BaseModel):
|
||||
active: bool
|
||||
created_at: UtcDateTime
|
||||
last_login: Optional[UtcDateTime] = None
|
||||
# #59: solo il fatto che il PIN esista, mai l'hash
|
||||
has_pin: bool = False
|
||||
|
||||
|
||||
class UserPasswordChange(BaseModel):
|
||||
@@ -68,3 +70,21 @@ class LoginResponse(BaseModel):
|
||||
user: UserResponse
|
||||
api_key: str
|
||||
message: str = "Login successful"
|
||||
|
||||
|
||||
class PinUserItem(BaseModel):
|
||||
"""Voce dell'elenco pubblico per il login con PIN (#59): solo id e nome."""
|
||||
id: int
|
||||
display_name: str
|
||||
|
||||
|
||||
class PinLoginRequest(BaseModel):
|
||||
"""Login con PIN (#59)."""
|
||||
user_id: int
|
||||
pin: str = Field(..., pattern=r"^[0-9]{4}$")
|
||||
|
||||
|
||||
class PinSet(BaseModel):
|
||||
"""Imposta (4 cifre) o toglie (null) il PIN di un utente (#59)."""
|
||||
pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||
|
||||
|
||||
@@ -16,6 +16,34 @@ class VisionExecuteResponse(BaseModel):
|
||||
vision_result_id: int
|
||||
engine_version: str
|
||||
measurements: list[VisionMeasurementResult]
|
||||
# Esito VisionSuite (informativo: il verdetto lo danno utl/ltl), overlay
|
||||
# in px immagine, URL dell'immagine salvata e strumenti falliti (#46).
|
||||
outcome: str | None = None
|
||||
overlay: list[dict] = []
|
||||
image_url: str | None = None
|
||||
failures: list[dict] = []
|
||||
|
||||
|
||||
class VisionRecipeOutput(BaseModel):
|
||||
"""One numeric output a recipe offers to the quotes."""
|
||||
key: str
|
||||
quantity: str | None = None
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
class VisionRecipeUploadResponse(BaseModel):
|
||||
task_id: int
|
||||
recipe_dir: str
|
||||
files: list[str]
|
||||
outputs: list[VisionRecipeOutput]
|
||||
|
||||
|
||||
class VisionRecipeResponse(BaseModel):
|
||||
task_id: int
|
||||
recipe_dir: str | None = None
|
||||
recipe: dict | None = None
|
||||
files: list[str]
|
||||
outputs: list[VisionRecipeOutput]
|
||||
|
||||
|
||||
class VisionReferenceImageResponse(BaseModel):
|
||||
|
||||
@@ -3,6 +3,7 @@ from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.models.orm.task_result import TaskResult
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||
@@ -17,6 +18,7 @@ __all__ = [
|
||||
"RecipeVersion",
|
||||
"RecipeTask",
|
||||
"RecipeSubtask",
|
||||
"TaskResult",
|
||||
"Measurement",
|
||||
"AccessLog",
|
||||
"SystemSetting",
|
||||
|
||||
@@ -25,6 +25,11 @@ class Station(Base):
|
||||
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
# URL del worker di visione della stazione (#45); NULL = worker centrale
|
||||
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
# camera della stazione (#72): URL del servizio camera e sorgente da chiedergli
|
||||
camera_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
camera_source: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
||||
Boolean, DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -15,10 +15,21 @@ 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", "dxf_compare", "camera_measure")
|
||||
# 'check' = Controllo OK/NOK, senza quote (#54).
|
||||
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure", "check")
|
||||
|
||||
# Types the operator screen treats as "there are quotes to take here".
|
||||
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
||||
# dxf_compare produce quote come camera_measure (#46)
|
||||
MEASURING_TASK_TYPES = ("measure", "camera_measure", "dxf_compare")
|
||||
|
||||
# Tipi che entrano nel ciclo di produzione se in_loop non e' dichiarato (#53):
|
||||
# le misure di sempre piu' il controllo OK/NOK.
|
||||
DEFAULT_LOOP_TASK_TYPES = MEASURING_TASK_TYPES + ("check",)
|
||||
|
||||
|
||||
def _default_in_loop(context) -> bool:
|
||||
"""Default alla creazione di in_loop: dipende dal tipo del task (#53)."""
|
||||
return context.get_current_parameters().get("task_type", "note") in DEFAULT_LOOP_TASK_TYPES
|
||||
|
||||
|
||||
class RecipeTask(Base):
|
||||
@@ -51,6 +62,20 @@ class RecipeTask(Base):
|
||||
# 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)
|
||||
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
||||
# meta della ricetta VisionSuite v2 (#46).
|
||||
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
|
||||
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
|
||||
in_loop: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=_default_in_loop, server_default="0"
|
||||
)
|
||||
# #53: task 'note' compilato alla fine produzione, prima della modale del capoturno.
|
||||
at_close: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
# #55: campi extra del task 'note' ([{key,label,type,default,attachment}]).
|
||||
fields: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""TaskResult: l'esito registrato da un task 'check' (OK/NOK) o 'note' (#54 #55)."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
TASK_OUTCOMES = ("ok", "nok", "done")
|
||||
|
||||
|
||||
class TaskResult(Base):
|
||||
__tablename__ = "task_results"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
# Copiata dal task: la versione in cui l'esito e' stato dato non cambia piu'.
|
||||
version_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
|
||||
)
|
||||
production_run_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("production_runs.id", ondelete="SET NULL"),
|
||||
nullable=True, index=True,
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
outcome: Mapped[str] = mapped_column(
|
||||
Enum(*TASK_OUTCOMES, name="task_outcome_enum"), nullable=False
|
||||
)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
# {chiave del campo: valore stringa}; allegati: [{key, path, name}]
|
||||
values: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
attachments: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||
created_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"<TaskResult task={self.task_id} outcome={self.outcome}>"
|
||||
@@ -22,6 +22,13 @@ class User(Base):
|
||||
# Admin flag separate from roles
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# PIN a 4 cifre (#59): solo l'hash; contatore errori e fine blocco per il lockout
|
||||
pin_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
pin_failed_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
pin_locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# User preferences
|
||||
language_pref: Mapped[str] = mapped_column(
|
||||
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
||||
@@ -39,6 +46,11 @@ class User(Base):
|
||||
)
|
||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
@property
|
||||
def has_pin(self) -> bool:
|
||||
"""True se l'utente ha un PIN impostato (#59); serve a UserResponse.has_pin."""
|
||||
return self.pin_hash is not None
|
||||
|
||||
def has_role(self, role: str) -> bool:
|
||||
"""Check if user has a specific role."""
|
||||
return role in (self.roles or [])
|
||||
|
||||
@@ -26,6 +26,8 @@ class VisionResult(Base):
|
||||
Integer, ForeignKey("stations.id"), nullable=True
|
||||
)
|
||||
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
# URL del worker effettivamente chiamato (#45), copiato: la stazione puo' cambiarlo dopo
|
||||
worker_url: Mapped[Optional[str]] = mapped_column(String(500), 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)
|
||||
|
||||
@@ -54,7 +54,79 @@ async def authenticate_user(
|
||||
return user
|
||||
|
||||
|
||||
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
|
||||
# #59: PIN a 4 cifre. Hash identico alle password (bcrypt); 5 errori -> blocco 15 minuti.
|
||||
PIN_MAX_FAILURES = 5
|
||||
PIN_LOCK_MINUTES = 15
|
||||
|
||||
|
||||
async def set_user_pin(db: AsyncSession, user: User, pin: str | None) -> None:
|
||||
"""Imposta o toglie (None) il PIN; azzera contatore e blocco. Admin esclusi (#59)."""
|
||||
if pin is not None and user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Admin users cannot have a PIN",
|
||||
)
|
||||
user.pin_hash = hash_password(pin) if pin is not None else None
|
||||
user.pin_failed_count = 0
|
||||
user.pin_locked_until = None
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def authenticate_pin(db: AsyncSession, user_id: int, pin: str) -> User:
|
||||
"""Verifica user_id+PIN con blocco dopo 5 errori consecutivi (#59).
|
||||
|
||||
401 PIN errato (o utente non idoneo: stessa risposta, niente enumerazione),
|
||||
423 utente bloccato. Il contatore va committato prima di sollevare: get_db fa
|
||||
rollback su eccezione e l'errore andrebbe perso.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.active == True) # noqa: E712
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or user.is_admin or user.pin_hash is None:
|
||||
verify_password(pin, _DUMMY_PASSWORD_HASH) # stesso costo di un PIN errato
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN"
|
||||
)
|
||||
now = utcnow()
|
||||
if user.pin_locked_until is not None:
|
||||
if user.pin_locked_until > now:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_423_LOCKED,
|
||||
detail="PIN locked until "
|
||||
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
|
||||
+ " UTC",
|
||||
)
|
||||
# blocco scaduto: si riparte da zero
|
||||
user.pin_locked_until = None
|
||||
user.pin_failed_count = 0
|
||||
if verify_password(pin, user.pin_hash):
|
||||
if user.pin_failed_count:
|
||||
user.pin_failed_count = 0
|
||||
await db.flush()
|
||||
return user
|
||||
user.pin_failed_count += 1
|
||||
locked = user.pin_failed_count >= PIN_MAX_FAILURES
|
||||
if locked:
|
||||
user.pin_locked_until = now + timedelta(minutes=PIN_LOCK_MINUTES)
|
||||
await db.commit()
|
||||
if locked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_423_LOCKED,
|
||||
detail="PIN locked until "
|
||||
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
|
||||
+ " UTC",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN")
|
||||
|
||||
|
||||
async def authorise_supervisor(
|
||||
db: AsyncSession,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
user_id: int | None = None,
|
||||
pin: str | None = None,
|
||||
) -> User:
|
||||
"""Check the credentials of the supervisor authorising something.
|
||||
|
||||
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
|
||||
@@ -65,7 +137,16 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
||||
Uses authenticate_user rather than a full login: logging in would issue a
|
||||
needless API key for the supervisor.
|
||||
"""
|
||||
supervisor = await authenticate_user(db, username, password)
|
||||
if user_id is not None and pin is not None:
|
||||
# #59: via PIN, stesso blocco del login con PIN (401/423 sollevati da li')
|
||||
supervisor = await authenticate_pin(db, user_id, pin)
|
||||
elif username is not None and password is not None:
|
||||
supervisor = await authenticate_user(db, username, password)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Supervisor credentials required",
|
||||
)
|
||||
if supervisor is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Live monitor of the open production runs (#57).
|
||||
|
||||
One query per table, whatever the number of runs: measurements and last events are
|
||||
ranked per run with a window function and cut in the database, not in a loop.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.api.monitor import MonitorEvent, MonitorRun, MonitorSubtask
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import production_service
|
||||
|
||||
# Recent measurements per run that feed counts and means (#57: last N).
|
||||
MONITOR_WINDOW = 20
|
||||
|
||||
|
||||
def _in_tolerance(mean: float, ltl, utl) -> bool:
|
||||
# A missing limit does not constrain.
|
||||
if ltl is not None and mean < float(ltl):
|
||||
return False
|
||||
if utl is not None and mean > float(utl):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
|
||||
runs = (await db.execute(
|
||||
select(ProductionRun)
|
||||
.where(ProductionRun.status.in_(("running", "paused")))
|
||||
.order_by(ProductionRun.started_at)
|
||||
)).scalars().all()
|
||||
if not runs:
|
||||
return []
|
||||
run_ids = [r.id for r in runs]
|
||||
|
||||
# Operators: one query.
|
||||
users = {
|
||||
u.id: u for u in (await db.execute(
|
||||
select(User).where(User.id.in_({r.operator_id for r in runs}))
|
||||
)).scalars().all()
|
||||
}
|
||||
|
||||
# Last event per run: rank desc inside the run, keep rank 1.
|
||||
ev_rank = func.row_number().over(
|
||||
partition_by=ProductionEvent.run_id,
|
||||
order_by=(ProductionEvent.created_at.desc(), ProductionEvent.id.desc()),
|
||||
).label("rn")
|
||||
ev_sub = select(
|
||||
ProductionEvent.run_id.label("run_id"),
|
||||
ProductionEvent.event_type.label("event_type"),
|
||||
ProductionEvent.created_at.label("created_at"),
|
||||
ProductionEvent.note.label("note"),
|
||||
ev_rank,
|
||||
).where(ProductionEvent.run_id.in_(run_ids)).subquery()
|
||||
ev_rows = (await db.execute(
|
||||
select(ev_sub.c.run_id, ev_sub.c.event_type, ev_sub.c.created_at, ev_sub.c.note)
|
||||
.where(ev_sub.c.rn == 1)
|
||||
)).all()
|
||||
last_event = {r.run_id: r for r in ev_rows}
|
||||
|
||||
# Last N measurements per run, joined to their subtask for the limits.
|
||||
m_rank = func.row_number().over(
|
||||
partition_by=Measurement.production_run_id,
|
||||
order_by=(Measurement.measured_at.desc(), Measurement.id.desc()),
|
||||
).label("rn")
|
||||
m_sub = select(
|
||||
Measurement.production_run_id.label("run_id"),
|
||||
Measurement.subtask_id.label("subtask_id"),
|
||||
Measurement.value.label("value"),
|
||||
Measurement.pass_fail.label("pass_fail"),
|
||||
m_rank,
|
||||
).where(Measurement.production_run_id.in_(run_ids)).subquery()
|
||||
m_rows = (await db.execute(
|
||||
select(
|
||||
m_sub.c.run_id, m_sub.c.subtask_id, m_sub.c.value, m_sub.c.pass_fail,
|
||||
RecipeSubtask.description, RecipeSubtask.unit,
|
||||
RecipeSubtask.ltl, RecipeSubtask.utl,
|
||||
)
|
||||
.join(RecipeSubtask, RecipeSubtask.id == m_sub.c.subtask_id)
|
||||
.where(m_sub.c.rn <= MONITOR_WINDOW)
|
||||
)).all()
|
||||
by_run = defaultdict(list)
|
||||
for row in m_rows:
|
||||
by_run[row.run_id].append(row)
|
||||
|
||||
result = []
|
||||
for run in runs:
|
||||
rows = by_run.get(run.id, [])
|
||||
per_sub: dict[int, list] = defaultdict(list)
|
||||
for row in rows:
|
||||
per_sub[row.subtask_id].append(row)
|
||||
subtasks = []
|
||||
for sid, group in sorted(per_sub.items()):
|
||||
mean = sum(float(g.value) for g in group) / len(group)
|
||||
first = group[0]
|
||||
subtasks.append(MonitorSubtask(
|
||||
subtask_id=sid, description=first.description, unit=first.unit,
|
||||
count=len(group), mean=round(mean, 6),
|
||||
ltl=float(first.ltl) if first.ltl is not None else None,
|
||||
utl=float(first.utl) if first.utl is not None else None,
|
||||
in_tolerance=_in_tolerance(mean, first.ltl, first.utl),
|
||||
))
|
||||
timer = production_service.describe(run)
|
||||
ev = last_event.get(run.id)
|
||||
operator = users.get(run.operator_id)
|
||||
result.append(MonitorRun(
|
||||
id=run.id,
|
||||
station_code=run.station.code, station_name=run.station.name,
|
||||
recipe_code=run.recipe.code, recipe_name=run.recipe.name,
|
||||
version_number=run.version.version_number,
|
||||
operator=(operator.display_name or operator.username) if operator else "",
|
||||
status=run.status,
|
||||
started_at=run.started_at,
|
||||
duration_seconds=max(0, int((timer["server_time"] - run.started_at).total_seconds())),
|
||||
cycle_count=run.cycle_count,
|
||||
seconds_to_next_measurement=timer["seconds_to_next_measurement"],
|
||||
overdue=timer["overdue"],
|
||||
last_event=MonitorEvent(
|
||||
event_type=ev.event_type, created_at=ev.created_at, note=ev.note,
|
||||
) if ev else None,
|
||||
measured=len(rows),
|
||||
pass_count=sum(1 for g in rows if g.pass_fail == "pass"),
|
||||
warning_count=sum(1 for g in rows if g.pass_fail == "warning"),
|
||||
fail_count=sum(1 for g in rows if g.pass_fail == "fail"),
|
||||
subtasks=subtasks,
|
||||
server_time=timer["server_time"],
|
||||
))
|
||||
return result
|
||||
@@ -145,7 +145,21 @@ async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||
select(RecipeTask.id)
|
||||
.where(
|
||||
RecipeTask.version_id == version_id,
|
||||
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
|
||||
# #53: il ciclo lo dichiara il task (in_loop), non piu' il solo tipo.
|
||||
RecipeTask.in_loop.is_(True),
|
||||
)
|
||||
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def close_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||
"""Ids dei task da compilare alla fine produzione (at_close), in ordine (#53)."""
|
||||
result = await db.execute(
|
||||
select(RecipeTask.id)
|
||||
.where(
|
||||
RecipeTask.version_id == version_id,
|
||||
RecipeTask.at_close.is_(True),
|
||||
)
|
||||
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||
)
|
||||
|
||||
@@ -115,6 +115,12 @@ async def _copy_tasks_to_version(
|
||||
# a camera_measure task unable to execute from the moment a
|
||||
# recipe with measurements is next edited.
|
||||
vision_json=task.vision_json,
|
||||
# Cartella immutabile: basta il riferimento (#46).
|
||||
vision_recipe_dir=task.vision_recipe_dir,
|
||||
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
|
||||
in_loop=task.in_loop,
|
||||
at_close=task.at_close,
|
||||
fields=task.fields,
|
||||
)
|
||||
db.add(new_task)
|
||||
await db.flush() # get new_task.id
|
||||
@@ -253,6 +259,78 @@ async def create_recipe(
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def duplicate_recipe(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
new_code: str,
|
||||
new_name: str,
|
||||
user: User,
|
||||
) -> Recipe:
|
||||
"""Duplicate a recipe (#52): new code/name, v1 with the current version's tasks.
|
||||
|
||||
The source recipe is left untouched.
|
||||
"""
|
||||
source = await _get_recipe_or_404(db, recipe_id)
|
||||
|
||||
existing = await db.execute(select(Recipe).where(Recipe.code == new_code))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Recipe code '{new_code}' already exists",
|
||||
)
|
||||
|
||||
# Same header fields create_recipe persists, taken from the source recipe
|
||||
recipe = Recipe(
|
||||
code=new_code,
|
||||
name=new_name,
|
||||
description=source.description,
|
||||
image_path=source.image_path,
|
||||
measurement_interval_minutes=source.measurement_interval_minutes,
|
||||
requires_lot=source.requires_lot,
|
||||
requires_serial=source.requires_serial,
|
||||
allow_manual_input=source.allow_manual_input,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(recipe)
|
||||
await db.flush()
|
||||
|
||||
version = RecipeVersion(
|
||||
recipe_id=recipe.id,
|
||||
version_number=1,
|
||||
is_current=True,
|
||||
created_by=user.id,
|
||||
change_notes=f"Duplicated from {source.code}",
|
||||
)
|
||||
db.add(version)
|
||||
await db.flush()
|
||||
|
||||
# Reuse the existing deep copy of tasks/subtasks from the current version
|
||||
source_version = await _get_current_version(db, source.id)
|
||||
if source_version is not None:
|
||||
await _copy_tasks_to_version(db, source_version, version)
|
||||
|
||||
await _write_audit(
|
||||
db,
|
||||
recipe_id=recipe.id,
|
||||
old_version_id=None,
|
||||
new_version_id=version.id,
|
||||
changed_by=user.id,
|
||||
change_type="CREATE",
|
||||
change_reason=f"Duplicated from recipe {source.code}",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(Recipe)
|
||||
.where(Recipe.id == recipe.id)
|
||||
.options(
|
||||
selectinload(Recipe.versions)
|
||||
.selectinload(RecipeVersion.tasks)
|
||||
.selectinload(RecipeTask.subtasks)
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def create_new_version(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
||||
import asyncio
|
||||
import base64
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from src.backend.utils.tempo import to_local, utcnow
|
||||
from pathlib import Path
|
||||
@@ -25,6 +26,23 @@ from src.backend.services.spc_service import (
|
||||
compute_summary,
|
||||
)
|
||||
|
||||
# Kaleido 1.x aggiunge alla pagina di render uno <script> MathJax dal CDN (cdnjs) anche se i grafici
|
||||
# non usano LaTeX: senza Internet (stand, PC di produzione) ogni grafico aspetta il timeout di rete
|
||||
# (~19 s con rete che scarta i pacchetti, TieMeasureFlow#78). I grafici qui sotto non hanno formule:
|
||||
# si passa a Kaleido un file locale vuoto al posto del CDN. Il file sta nella cartella temporanea.
|
||||
def _mathjax_vuoto() -> str:
|
||||
"""Percorso di un .js vuoto da usare come MathJax: nessuna richiesta di rete per i grafici."""
|
||||
path = Path(tempfile.gettempdir()) / "tmf-mathjax-vuoto.js"
|
||||
if not path.exists():
|
||||
path.write_text("/* MathJax non usato: grafici senza LaTeX */", encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
try:
|
||||
pio.defaults.mathjax = _mathjax_vuoto()
|
||||
except (AttributeError, OSError): # plotly vecchio senza pio.defaults o temp non scrivibile: resta il CDN
|
||||
pass
|
||||
|
||||
# Jinja2 environment for report templates
|
||||
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
|
||||
_jinja_env = Environment(
|
||||
@@ -48,6 +66,7 @@ async def _query_measurements(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58
|
||||
) -> list[Measurement]:
|
||||
"""Query measurements with common filters (duplicated from statistics router)."""
|
||||
filters = []
|
||||
@@ -73,6 +92,9 @@ async def _query_measurements(
|
||||
if serial_number is not None:
|
||||
filters.append(Measurement.serial_number == serial_number)
|
||||
|
||||
if production_run_id is not None:
|
||||
filters.append(Measurement.production_run_id == production_run_id)
|
||||
|
||||
query = (
|
||||
select(Measurement)
|
||||
.where(and_(*filters) if filters else True)
|
||||
@@ -281,6 +303,7 @@ async def generate_spc_report(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58
|
||||
) -> bytes:
|
||||
"""Generate SPC PDF report and return bytes.
|
||||
|
||||
@@ -290,6 +313,7 @@ async def generate_spc_report(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
|
||||
# 2. Compute SPC data
|
||||
@@ -336,6 +360,8 @@ async def generate_spc_report(
|
||||
filters_desc.append(f"Lot: {lot_number}")
|
||||
if serial_number:
|
||||
filters_desc.append(f"Serial: {serial_number}")
|
||||
if production_run_id is not None:
|
||||
filters_desc.append(f"Run: {production_run_id}") # #58
|
||||
|
||||
# 6. Render HTML template
|
||||
template = _jinja_env.get_template("spc_report.html")
|
||||
@@ -367,12 +393,14 @@ async def generate_measurement_report(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58
|
||||
) -> bytes:
|
||||
"""Generate measurement table PDF report and return bytes."""
|
||||
# 1. Query measurements
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
|
||||
# 2. Get recipe info
|
||||
@@ -427,6 +455,8 @@ async def generate_measurement_report(
|
||||
filters_desc.append(f"Lot: {lot_number}")
|
||||
if serial_number:
|
||||
filters_desc.append(f"Serial: {serial_number}")
|
||||
if production_run_id is not None:
|
||||
filters_desc.append(f"Run: {production_run_id}") # #58
|
||||
|
||||
# 7. Render template
|
||||
template = _jinja_env.get_template("measurement_report.html")
|
||||
|
||||
@@ -31,6 +31,9 @@ async def create_station(
|
||||
location=data.location,
|
||||
notes=data.notes,
|
||||
active=data.active,
|
||||
vision_worker_url=data.vision_worker_url, # #45
|
||||
camera_url=data.camera_url, # #72
|
||||
camera_source=data.camera_source, # #72
|
||||
created_by=creator.id,
|
||||
)
|
||||
db.add(station)
|
||||
|
||||
@@ -5,6 +5,7 @@ to the worker over HTTP and stays ignorant of how a number was produced.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
@@ -18,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
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
|
||||
@@ -46,7 +48,14 @@ def _is_measurable(value: object) -> bool:
|
||||
)
|
||||
|
||||
|
||||
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def _call_worker(
|
||||
image_bytes: bytes,
|
||||
graph: dict,
|
||||
*,
|
||||
recipe_dir: str | None = None,
|
||||
calibration: dict | None = None,
|
||||
worker_url: str | None = None,
|
||||
) -> 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
|
||||
@@ -60,22 +69,31 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||
is not the worker's JSON contract must not turn into a bare KeyError
|
||||
further down.
|
||||
"""
|
||||
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
|
||||
# si mandano, il worker usa i suoi default.
|
||||
data = {"graph": json.dumps(graph)}
|
||||
# worker della stazione (#45) o, se manca, il centrale; mai un ripiego dall'uno all'altro
|
||||
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||
if recipe_dir:
|
||||
data["recipe_dir"] = recipe_dir
|
||||
if calibration:
|
||||
data["calibration"] = json.dumps(calibration)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.vision_worker_url}/run",
|
||||
f"{base}/run",
|
||||
files={"image": ("frame.png", image_bytes, "image/png")},
|
||||
data={"graph": json.dumps(graph)},
|
||||
data=data,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail=f"the vision worker did not answer in time: {exc}",
|
||||
detail=f"the vision worker did not answer in time: {exc} ({base})", # #45
|
||||
) from exc
|
||||
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker is unreachable: {exc}",
|
||||
detail=f"the vision worker is unreachable: {exc} ({base})", # #45
|
||||
) from exc
|
||||
|
||||
# The worker already gave this fault a name - forward its status and
|
||||
@@ -122,6 +140,188 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||
return report
|
||||
|
||||
|
||||
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
||||
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
||||
|
||||
Returns {"ok", "errors", "outputs"}. A worker 422 (e.g. recipe_dir
|
||||
refused) is folded into ok=False; transport faults become 502/504 as in
|
||||
`_call_worker`. Replaced by the tests.
|
||||
"""
|
||||
data = {"graph": json.dumps(graph)}
|
||||
if recipe_dir:
|
||||
data["recipe_dir"] = recipe_dir
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.vision_worker_url}/validate", data=data,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail=f"the vision worker did not answer in time: {exc}",
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker is unreachable: {exc}",
|
||||
) from exc
|
||||
if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT:
|
||||
try:
|
||||
detail = response.json().get("detail", response.text)
|
||||
except ValueError:
|
||||
detail = response.text
|
||||
errors = detail if isinstance(detail, list) else [str(detail)]
|
||||
return {"ok": False, "errors": [str(e) for e in errors], "outputs": []}
|
||||
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:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: not valid JSON",
|
||||
) from exc
|
||||
if not isinstance(body, dict) or "ok" not in body:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: missing ok",
|
||||
)
|
||||
body.setdefault("errors", [])
|
||||
body.setdefault("outputs", [])
|
||||
return body
|
||||
|
||||
|
||||
# Task types that run a vision graph (#46).
|
||||
VISION_TASK_TYPES = ("camera_measure", "dxf_compare")
|
||||
|
||||
|
||||
def _safe_join(root: Path, relative: str) -> Path:
|
||||
"""root/relative, refusing absolute paths, '..' and anything leaving root."""
|
||||
candidate = Path(relative)
|
||||
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
|
||||
raise ValueError(f"unsafe path: {relative}")
|
||||
full = (root / candidate).resolve()
|
||||
if not full.is_relative_to(root.resolve()):
|
||||
raise ValueError(f"unsafe path: {relative}")
|
||||
return full
|
||||
|
||||
|
||||
def collect_meta_paths(node: object) -> list[str]:
|
||||
"""Every `path` string found in any dict under the recipe's `meta`."""
|
||||
found: list[str] = []
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if key == "path" and isinstance(value, str):
|
||||
found.append(value)
|
||||
else:
|
||||
found.extend(collect_meta_paths(value))
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
found.extend(collect_meta_paths(item))
|
||||
return found
|
||||
|
||||
|
||||
def match_recipe_files(
|
||||
recipe: dict, uploads: list[tuple[str, bytes]],
|
||||
) -> tuple[dict[str, bytes], list[str]]:
|
||||
"""Pair each uploaded file with a meta path by base name (#46).
|
||||
|
||||
Returns ({relative path: bytes}, errors). Errors list every upload that
|
||||
has no name or matches no meta path, and every unsafe meta path. A meta
|
||||
path left without a file is not an error here: /validate decides.
|
||||
"""
|
||||
paths = collect_meta_paths(recipe.get("meta", {}))
|
||||
by_name: dict[str, str] = {}
|
||||
for p in paths:
|
||||
by_name.setdefault(Path(p.replace("\\", "/")).name, p)
|
||||
placed: dict[str, bytes] = {}
|
||||
errors: list[str] = []
|
||||
for name, content in uploads:
|
||||
if not name:
|
||||
errors.append("a file has no name")
|
||||
continue
|
||||
target = by_name.get(Path(name.replace("\\", "/")).name)
|
||||
if target is None:
|
||||
errors.append(f"file {name!r} matches no path in the recipe's meta")
|
||||
continue
|
||||
try:
|
||||
_safe_join(Path("."), target)
|
||||
except ValueError:
|
||||
errors.append(f"meta path {target!r} is not allowed")
|
||||
continue
|
||||
placed[target] = content
|
||||
return placed, errors
|
||||
|
||||
|
||||
def store_recipe_folder(
|
||||
task_id: int, recipe: dict, files: dict[str, bytes],
|
||||
) -> str:
|
||||
"""Write the immutable recipe folder; return its path relative to uploads.
|
||||
|
||||
Folder name = first 16 hex of the recipe's sha256. If it already exists it
|
||||
is left untouched (immutable: other task versions may reference it).
|
||||
"""
|
||||
canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":"))
|
||||
# Il digest copre anche i file (path ordinato + sha256 del contenuto): la
|
||||
# stessa ricetta con file diversi (es. primo upload senza il DXF) deve dare
|
||||
# una cartella diversa, altrimenti quella incompleta resterebbe per sempre
|
||||
# perche' immutabile (#46).
|
||||
h = hashlib.sha256(canonical.encode("utf-8"))
|
||||
for rel in sorted(files):
|
||||
h.update(b"\0" + rel.encode("utf-8") + b"\0")
|
||||
h.update(hashlib.sha256(files[rel]).digest())
|
||||
digest = h.hexdigest()[:16]
|
||||
relative = f"vision/recipes/{task_id}/{digest}"
|
||||
folder = settings.upload_path / relative
|
||||
if folder.exists():
|
||||
return relative
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / "recipe.json").write_text(
|
||||
json.dumps(recipe, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||
)
|
||||
for rel, content in files.items():
|
||||
target = _safe_join(folder, rel)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
return relative
|
||||
|
||||
|
||||
def list_recipe_files(recipe_dir: str | None) -> list[str]:
|
||||
"""Files of a recipe folder, relative to it (recipe.json excluded)."""
|
||||
if not recipe_dir:
|
||||
return []
|
||||
try:
|
||||
folder = _safe_join(settings.upload_path, recipe_dir)
|
||||
except ValueError:
|
||||
return []
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
p.relative_to(folder).as_posix()
|
||||
for p in folder.rglob("*") if p.is_file() and p.name != "recipe.json"
|
||||
)
|
||||
|
||||
|
||||
def save_result_files(
|
||||
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
||||
) -> tuple[str, str]:
|
||||
"""Save the executed image and its overlay JSON side by side (#46).
|
||||
|
||||
Returns (image_path, overlay_path), both relative to uploads.
|
||||
"""
|
||||
stem = uuid4().hex
|
||||
base = Path("vision") / "results" / str(task_id)
|
||||
(settings.upload_path / base).mkdir(parents=True, exist_ok=True)
|
||||
image_rel = (base / f"{stem}.{image_ext}").as_posix()
|
||||
overlay_rel = (base / f"{stem}.overlay.json").as_posix()
|
||||
(settings.upload_path / image_rel).write_bytes(image_bytes)
|
||||
(settings.upload_path / overlay_rel).write_text(json.dumps(overlay), encoding="utf-8")
|
||||
return image_rel, overlay_rel
|
||||
|
||||
|
||||
async def execute_task(
|
||||
db: AsyncSession,
|
||||
task: RecipeTask,
|
||||
@@ -132,8 +332,9 @@ async def execute_task(
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None,
|
||||
) -> tuple[VisionResult, list[Measurement]]:
|
||||
if task.task_type != "camera_measure":
|
||||
image_ext: str = "png",
|
||||
) -> tuple[VisionResult, list[Measurement], dict]:
|
||||
if task.task_type not in VISION_TASK_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"task type {task.task_type!r} is not a camera measurement",
|
||||
@@ -155,12 +356,20 @@ async def execute_task(
|
||||
detail="this recipe is not active",
|
||||
)
|
||||
|
||||
run = None
|
||||
if production_run_id is not None:
|
||||
# 404 if the run does not exist, 422 if it belongs to another recipe,
|
||||
# 409 if it is closed or paused - same rule a manual reading follows.
|
||||
await measurement_service.validate_run_for_version(
|
||||
run = await measurement_service.validate_run_for_version(
|
||||
db, production_run_id, version_id,
|
||||
)
|
||||
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||
station_url = None
|
||||
if run is not None:
|
||||
station = (await db.execute(
|
||||
select(Station).where(Station.id == run.station_id)
|
||||
)).scalar_one_or_none()
|
||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||
|
||||
if not task.vision_json:
|
||||
raise HTTPException(
|
||||
@@ -210,7 +419,12 @@ async def execute_task(
|
||||
),
|
||||
)
|
||||
|
||||
report = await _call_worker(image_bytes, task.vision_json)
|
||||
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
|
||||
# passa `calibration`, la ricetta porta la sua (#46).
|
||||
report = await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
worker_url=station_url, # #45
|
||||
)
|
||||
outputs = report["outputs"]
|
||||
|
||||
# Every quote or none. A half-measured piece read as a whole one is worse
|
||||
@@ -231,10 +445,17 @@ async def execute_task(
|
||||
),
|
||||
)
|
||||
|
||||
overlay = report.get("overlay") or []
|
||||
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
|
||||
result = VisionResult(
|
||||
task_id=task.id,
|
||||
image_path=image_path,
|
||||
overlay_path=overlay_path,
|
||||
engine_version=report["engine_version"],
|
||||
executed_on="server",
|
||||
# #45: dove e' girato davvero, e quale worker (copiato come lo snapshot)
|
||||
executed_on="station" if station_url else "server",
|
||||
station_id=run.station_id if run is not None else None,
|
||||
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||
graph_snapshot=task.vision_json,
|
||||
duration_ms=report.get("duration_ms"),
|
||||
)
|
||||
@@ -257,7 +478,7 @@ async def execute_task(
|
||||
enforce_tolerance_gate=False,
|
||||
))
|
||||
|
||||
return result, saved
|
||||
return result, saved, report
|
||||
|
||||
|
||||
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
||||
@@ -377,4 +598,6 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="this task carries no vision graph",
|
||||
)
|
||||
return await _call_worker(image_bytes, task.vision_json)
|
||||
return await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,10 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.operations import Operations
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.config import Config
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
||||
|
||||
from src.backend.config import Settings
|
||||
@@ -131,3 +134,210 @@ class TestApiKeyHashMigration:
|
||||
|
||||
command.upgrade(cfg, "head") # and up again
|
||||
assert "api_keys" in _inspect(db_path).get_table_names()
|
||||
|
||||
def test_015_on_create_all_schema(self, sqlite_database_url):
|
||||
"""DB nato da create_all: users.api_key ha solo l'indice unique
|
||||
ix_users_api_key, nessun vincolo uq_users_api_key (#71)."""
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
ops = Operations(ctx)
|
||||
with ops.batch_alter_table("users") as batch:
|
||||
batch.drop_index("ix_users_api_key")
|
||||
batch.drop_constraint("uq_users_api_key", type_="unique")
|
||||
ops.create_index("ix_users_api_key", "users", ["api_key"], unique=True)
|
||||
engine.dispose()
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert not insp.get_unique_constraints("users") or "uq_users_api_key" not in {
|
||||
u["name"] for u in insp.get_unique_constraints("users")
|
||||
}
|
||||
|
||||
command.upgrade(cfg, "head") # non deve sollevare
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert "api_keys" in insp.get_table_names()
|
||||
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||
|
||||
def test_015_resumes_after_partial_run(self, sqlite_database_url):
|
||||
"""015 a meta' (MySQL, DDL non transazionale): api_keys creata e
|
||||
ix_users_api_key tolto, alembic_version ancora 014 (#71)."""
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
ops = Operations(MigrationContext.configure(conn))
|
||||
ops.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("key_hash", sa.String(64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"], ["users.id"],
|
||||
name="fk_api_keys_user_id", ondelete="CASCADE",
|
||||
),
|
||||
)
|
||||
ops.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
|
||||
ops.drop_index("ix_users_api_key", table_name="users")
|
||||
engine.dispose()
|
||||
|
||||
command.upgrade(cfg, "head") # non deve sollevare
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert "api_keys" in insp.get_table_names()
|
||||
assert {i["name"] for i in insp.get_indexes("api_keys")} >= {
|
||||
"ix_api_keys_key_hash", "ix_api_keys_user_id",
|
||||
}
|
||||
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||
|
||||
|
||||
class TestVisionRecipeDirMigration:
|
||||
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
||||
|
||||
def test_016_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
def cols():
|
||||
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert "vision_recipe_dir" in cols()
|
||||
command.downgrade(cfg, "015_api_key_hash")
|
||||
assert "vision_recipe_dir" not in cols()
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert "vision_recipe_dir" in cols()
|
||||
|
||||
|
||||
class TestUserPinMigration:
|
||||
"""017_user_pin: users.pin_hash / pin_failed_count / pin_locked_until, su e giu (#59)."""
|
||||
|
||||
def test_017_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
new = {"pin_hash", "pin_failed_count", "pin_locked_until"}
|
||||
|
||||
def cols():
|
||||
return {c["name"] for c in _inspect(db_path).get_columns("users")}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert new <= cols()
|
||||
command.downgrade(cfg, "016_vision_recipe_dir")
|
||||
assert not (new & cols())
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert new <= cols()
|
||||
|
||||
|
||||
_SEED_USER_RECIPE_VERSION = [
|
||||
"INSERT INTO users (username, password_hash, display_name, roles, is_admin, active,"
|
||||
" language_pref, theme_pref) VALUES ('u', 'x', 'U', '[]', 1, 1, 'en', 'light')",
|
||||
"INSERT INTO recipes (code, name, active, created_by) VALUES ('R', 'R', 1, 1)",
|
||||
"INSERT INTO recipe_versions (recipe_id, version_number, is_current, created_by)"
|
||||
" VALUES (1, 1, 1, 1)",
|
||||
]
|
||||
|
||||
|
||||
class TestTaskLoopResultsMigration:
|
||||
"""018_task_loop_results: in_loop/at_close/fields, tipo check, task_results (#53 #54 #55)."""
|
||||
|
||||
def test_018_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
def cols():
|
||||
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert {"in_loop", "at_close", "fields"} <= cols()
|
||||
assert "task_results" in _inspect(db_path).get_table_names()
|
||||
command.downgrade(cfg, "017_user_pin")
|
||||
assert not ({"in_loop", "at_close", "fields"} & cols())
|
||||
assert "task_results" not in _inspect(db_path).get_table_names()
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
|
||||
def test_018_marks_existing_measure_tasks_in_loop(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "017_user_pin")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
for stmt in _SEED_USER_RECIPE_VERSION:
|
||||
conn.execute(text(stmt))
|
||||
for i, t in enumerate(("measure", "camera_measure", "dxf_compare", "note", "drawing")):
|
||||
conn.execute(text(
|
||||
"INSERT INTO recipe_tasks (version_id, order_index, task_type, title)"
|
||||
f" VALUES (1, {i}, '{t}', '{t}')"))
|
||||
engine.dispose()
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.connect() as conn:
|
||||
rows = dict(conn.execute(text("SELECT task_type, in_loop FROM recipe_tasks")).all())
|
||||
engine.dispose()
|
||||
assert rows == {"measure": 1, "camera_measure": 1, "dxf_compare": 1, "note": 0, "drawing": 0}
|
||||
|
||||
def test_018_downgrade_refused_when_a_check_task_exists(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "head")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
for stmt in _SEED_USER_RECIPE_VERSION:
|
||||
conn.execute(text(stmt))
|
||||
conn.execute(text(
|
||||
"INSERT INTO recipe_tasks (version_id, order_index, task_type, title)"
|
||||
" VALUES (1, 0, 'check', 'c')"))
|
||||
engine.dispose()
|
||||
|
||||
with pytest.raises(RuntimeError, match="check"):
|
||||
command.downgrade(cfg, "017_user_pin")
|
||||
|
||||
|
||||
class TestStationVisionWorkerMigration:
|
||||
"""019_station_vision_worker: stations.vision_worker_url e vision_results.worker_url, su e giu (#45)."""
|
||||
|
||||
def test_019_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
def cols(table):
|
||||
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert "vision_worker_url" in cols("stations")
|
||||
assert "worker_url" in cols("vision_results")
|
||||
command.downgrade(cfg, "018_task_loop_results")
|
||||
assert "vision_worker_url" not in cols("stations")
|
||||
assert "worker_url" not in cols("vision_results")
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert "vision_worker_url" in cols("stations")
|
||||
assert "worker_url" in cols("vision_results")
|
||||
|
||||
|
||||
class TestStationCameraMigration:
|
||||
"""020_station_camera: stations.camera_url e stations.camera_source, su e giu (#72)."""
|
||||
|
||||
def test_020_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
def cols(table):
|
||||
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||
command.downgrade(cfg, "019_station_vision_worker")
|
||||
assert "camera_url" not in cols("stations")
|
||||
assert "camera_source" not in cols("stations")
|
||||
assert "vision_worker_url" in cols("stations") # la 019 resta
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""GET /api/production-runs/open - live monitor of the open runs (#57)."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.tests.conftest import _create_user, auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def _run(db, user, code, status="running", overdue=False, open_=True):
|
||||
"""A station plus a run on a fresh recipe (limits ltl 9.5 / utl 10.5)."""
|
||||
station = Station(code=f"ST-{code}", name=f"Linea {code}", active=True, created_by=user.id)
|
||||
db.add(station)
|
||||
recipe = await create_test_recipe(db, user_id=user.id, code=f"REC-{code}")
|
||||
await db.flush()
|
||||
version = recipe.versions[0]
|
||||
now = datetime.utcnow()
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=user.id, status=status if open_ else "closed",
|
||||
started_at=now - timedelta(hours=1),
|
||||
measurement_interval_minutes=30,
|
||||
next_measurement_at=now + (timedelta(minutes=-5) if overdue else timedelta(minutes=20)),
|
||||
cycle_count=3,
|
||||
active_station_id=station.id if open_ else None,
|
||||
)
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
db.add(ProductionEvent(run_id=run.id, event_type="start", user_id=user.id))
|
||||
sub = (await db.execute(
|
||||
select(RecipeSubtask).join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
|
||||
.where(RecipeTask.version_id == version.id)
|
||||
)).scalars().first()
|
||||
return run, sub, version
|
||||
|
||||
|
||||
def _m(run, sub, version, user, value, pf, minutes=0):
|
||||
return Measurement(
|
||||
subtask_id=sub.id, version_id=version.id, measured_by=user.id, value=value,
|
||||
pass_fail=pf, production_run_id=run.id,
|
||||
measured_at=datetime.utcnow() - timedelta(minutes=minutes),
|
||||
)
|
||||
|
||||
|
||||
async def test_open_requires_high_authority(client, db_session, measurement_tec_user):
|
||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(measurement_tec_user))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_open_lists_only_open_runs_with_means(client, db_session, admin_user):
|
||||
sup = await _create_user(db_session, "capo", roles=["Supervisor"])
|
||||
r1, s1, v1 = await _run(db_session, admin_user, "A", overdue=True)
|
||||
r2, s2, v2 = await _run(db_session, admin_user, "B", status="paused")
|
||||
await _run(db_session, admin_user, "C", open_=False)
|
||||
# Run A: mean 10.0 (inside), run B: mean 10.8 (outside, one fail).
|
||||
for i, (val, pf) in enumerate([(9.9, "pass"), (10.1, "pass")]):
|
||||
db_session.add(_m(r1, s1, v1, admin_user, val, pf, i))
|
||||
for i, (val, pf) in enumerate([(10.6, "fail"), (11.0, "fail"), (10.8, "warning")]):
|
||||
db_session.add(_m(r2, s2, v2, admin_user, val, pf, i))
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(sup))
|
||||
assert resp.status_code == 200
|
||||
body = {r["station_code"]: r for r in resp.json()}
|
||||
assert set(body) == {"ST-A", "ST-B"} # closed run C is absent
|
||||
a, b = body["ST-A"], body["ST-B"]
|
||||
assert a["overdue"] is True and a["seconds_to_next_measurement"] < 0
|
||||
assert b["overdue"] is False and b["status"] == "paused"
|
||||
assert a["duration_seconds"] >= 3600 and a["cycle_count"] == 3
|
||||
assert a["last_event"]["event_type"] == "start"
|
||||
assert a["pass_count"] == 2 and a["fail_count"] == 0
|
||||
assert a["subtasks"][0]["mean"] == 10.0 and a["subtasks"][0]["in_tolerance"] is True
|
||||
assert b["fail_count"] == 2 and b["warning_count"] == 1
|
||||
assert round(b["subtasks"][0]["mean"], 3) == 10.8
|
||||
assert b["subtasks"][0]["in_tolerance"] is False
|
||||
|
||||
|
||||
async def test_open_empty_for_admin(client, admin_user):
|
||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
|
||||
assert resp.status_code == 200 and resp.json() == []
|
||||
@@ -0,0 +1,175 @@
|
||||
"""PIN a 4 cifre: login rapido, blocco, capoturno via PIN (#59)."""
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import auth_service
|
||||
from src.backend.tests.conftest import _create_user, auth_headers
|
||||
from src.backend.tests.test_production_runs import (
|
||||
_open, _recipe_with_interval, _station,
|
||||
)
|
||||
from src.backend.utils.tempo import utcnow
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _alza_limite_login(monkeypatch):
|
||||
# #65: login-pin conta nel bucket del login (limite al minuto); i test del blocco PIN
|
||||
# fanno piu tentativi di fila, quindi qui il limite si alza. Il middleware resta com'e.
|
||||
monkeypatch.setattr(settings, "rate_limit_login", 1000)
|
||||
|
||||
|
||||
async def _pin_user(db_session, username="operaio", pin="1234", **kw) -> User:
|
||||
user = await _create_user(db_session, username=username, **kw)
|
||||
await auth_service.set_user_pin(db_session, user, pin)
|
||||
await db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
async def _login_pin(client, user_id, pin):
|
||||
return await client.post("/api/auth/login-pin", json={"user_id": user_id, "pin": pin})
|
||||
|
||||
|
||||
async def test_login_pin_ok_same_shape_as_login(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
resp = await _login_pin(client, user.id, "1234")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["user"]["username"] == "operaio"
|
||||
assert data["user"]["has_pin"] is True
|
||||
assert data["api_key"]
|
||||
assert "pin_hash" not in data["user"]
|
||||
# la api_key vale davvero
|
||||
me = await client.get("/api/auth/me", headers={"X-API-Key": data["api_key"]})
|
||||
assert me.status_code == 200
|
||||
|
||||
|
||||
async def test_login_pin_wrong_is_401(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
assert (await _login_pin(client, user.id, "9999")).status_code == 401
|
||||
|
||||
|
||||
async def test_login_pin_format_422(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
for bad in ("123", "12345", "12a4", ""):
|
||||
assert (await _login_pin(client, user.id, bad)).status_code == 422
|
||||
|
||||
|
||||
async def test_lock_after_five_errors_even_with_right_pin(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
for _ in range(4):
|
||||
assert (await _login_pin(client, user.id, "0000")).status_code == 401
|
||||
resp = await _login_pin(client, user.id, "0000") # 5 errore: scatta il blocco
|
||||
assert resp.status_code == 423
|
||||
assert "locked until" in resp.json()["detail"]
|
||||
assert (await _login_pin(client, user.id, "1234")).status_code == 423
|
||||
|
||||
|
||||
async def test_lock_expires_and_counter_resets(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
for _ in range(5):
|
||||
await _login_pin(client, user.id, "0000")
|
||||
row = (await db_session.execute(select(User).where(User.id == user.id))).scalar_one()
|
||||
await db_session.refresh(row)
|
||||
assert row.pin_locked_until > utcnow() + timedelta(minutes=14)
|
||||
# tempo simulato: il blocco e scaduto un secondo fa
|
||||
row.pin_locked_until = utcnow() - timedelta(seconds=1)
|
||||
await db_session.commit()
|
||||
assert (await _login_pin(client, user.id, "1234")).status_code == 200
|
||||
await db_session.refresh(row)
|
||||
assert row.pin_failed_count == 0 and row.pin_locked_until is None
|
||||
|
||||
|
||||
async def test_success_resets_failed_counter(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
for _ in range(3):
|
||||
await _login_pin(client, user.id, "0000")
|
||||
assert (await _login_pin(client, user.id, "1234")).status_code == 200
|
||||
for _ in range(4): # il contatore e ripartito da zero: 4 errori non bloccano
|
||||
assert (await _login_pin(client, user.id, "0000")).status_code == 401
|
||||
|
||||
|
||||
async def test_admin_cannot_have_pin_or_login_with_it(client: AsyncClient, admin_user, db_session):
|
||||
resp = await client.put(
|
||||
f"/api/users/{admin_user.id}/pin", headers=auth_headers(admin_user), json={"pin": "1234"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
# anche un admin con pin_hash scritto a mano non entra da login-pin
|
||||
admin_user.pin_hash = auth_service.hash_password("1234")
|
||||
await db_session.commit()
|
||||
assert (await _login_pin(client, admin_user.id, "1234")).status_code == 401
|
||||
listed = (await client.get("/api/auth/pin-users")).json()
|
||||
assert all(u["id"] != admin_user.id for u in listed)
|
||||
|
||||
|
||||
async def test_put_pin_set_and_remove(client: AsyncClient, admin_user, db_session):
|
||||
user = await _create_user(db_session, username="senzapin")
|
||||
await db_session.commit()
|
||||
h = auth_headers(admin_user)
|
||||
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "4321"})).status_code == 200
|
||||
assert (await _login_pin(client, user.id, "4321")).status_code == 200
|
||||
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "12"})).status_code == 422
|
||||
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": None})).status_code == 200
|
||||
assert (await _login_pin(client, user.id, "4321")).status_code == 401
|
||||
assert (await client.put("/api/users/99999/pin", headers=h, json={"pin": "1234"})).status_code == 404
|
||||
# non admin: 403
|
||||
assert (await client.put(
|
||||
f"/api/users/{user.id}/pin", headers=auth_headers(user), json={"pin": "1234"},
|
||||
)).status_code == 403
|
||||
|
||||
|
||||
async def test_pin_users_list(client: AsyncClient, db_session):
|
||||
a = await _pin_user(db_session, username="zeta", display_name="Zeta Rossi", pin="1111")
|
||||
b = await _pin_user(db_session, username="alfa", display_name="Alfa Bianchi", pin="2222")
|
||||
await _create_user(db_session, username="nopin")
|
||||
await _pin_user(db_session, username="inattivo", pin="3333", active=False)
|
||||
await db_session.commit()
|
||||
resp = await client.get("/api/auth/pin-users") # senza autenticazione
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert [u["display_name"] for u in data] == ["Alfa Bianchi", "Zeta Rossi"]
|
||||
assert all(set(u) == {"id", "display_name"} for u in data)
|
||||
assert {u["id"] for u in data} == {a.id, b.id}
|
||||
|
||||
|
||||
async def test_supervisor_via_pin(client: AsyncClient, measurement_tec_user, admin_user, db_session):
|
||||
station = await _station(db_session, admin_user.id, code="ST-PIN")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PIN")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
capo = await _pin_user(db_session, username="capo-pin", pin="5555", roles=["Supervisor"])
|
||||
h = auth_headers(measurement_tec_user)
|
||||
|
||||
bad = await client.post(
|
||||
f"/api/production-runs/{run_id}/pause", headers=h,
|
||||
json={"supervisor_user_id": capo.id, "supervisor_pin": "0000"},
|
||||
)
|
||||
assert bad.status_code == 401
|
||||
ok = await client.post(
|
||||
f"/api/production-runs/{run_id}/pause", headers=h,
|
||||
json={"supervisor_user_id": capo.id, "supervisor_pin": "5555"},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
# nessuna coppia di credenziali: 422
|
||||
none = await client.post(f"/api/production-runs/{run_id}/resume", headers=h, json={})
|
||||
assert none.status_code == 422
|
||||
|
||||
|
||||
async def test_supervisor_pin_role_still_checked(db_session):
|
||||
plain = await _pin_user(db_session, username="non-capo", pin="6666", roles=["Maker"])
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await auth_service.authorise_supervisor(db_session, user_id=plain.id, pin="6666")
|
||||
assert e.value.status_code == 403
|
||||
|
||||
|
||||
async def test_supervisor_pin_lockout(db_session):
|
||||
capo = await _pin_user(db_session, username="capo-lock", pin="7777", roles=["Supervisor"])
|
||||
codes = []
|
||||
for pin in ["0000"] * 5 + ["7777"]:
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await auth_service.authorise_supervisor(db_session, user_id=capo.id, pin=pin)
|
||||
codes.append(e.value.status_code)
|
||||
assert codes == [401, 401, 401, 401, 423, 423]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for POST /api/recipes/{id}/duplicate (#52)."""
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
class TestDuplicateRecipe:
|
||||
async def test_duplicate_copies_tasks_and_keeps_original(
|
||||
self, client: AsyncClient, maker_user: User, db_session: AsyncSession
|
||||
):
|
||||
src = await create_test_recipe(db_session, maker_user.id)
|
||||
src_id = src.id
|
||||
before_tasks = (await db_session.execute(select(func.count(RecipeTask.id)))).scalar_one()
|
||||
before_subs = (await db_session.execute(select(func.count(RecipeSubtask.id)))).scalar_one()
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/recipes/{src_id}/duplicate",
|
||||
json={"code": "REC-COPIA", "name": "Copia"},
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["code"] == "REC-COPIA" and data["name"] == "Copia"
|
||||
assert data["id"] != src_id
|
||||
assert data["description"] == "A recipe for testing"
|
||||
assert data["current_version"]["version_number"] == 1
|
||||
|
||||
# Tasks and subtasks doubled: copied, not moved
|
||||
after_tasks = (await db_session.execute(select(func.count(RecipeTask.id)))).scalar_one()
|
||||
after_subs = (await db_session.execute(select(func.count(RecipeSubtask.id)))).scalar_one()
|
||||
assert after_tasks == before_tasks * 2
|
||||
assert after_subs == before_subs * 2
|
||||
|
||||
new_version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == data["id"])
|
||||
)).scalar_one()
|
||||
new_tasks = (await db_session.execute(
|
||||
select(RecipeTask).where(RecipeTask.version_id == new_version.id)
|
||||
)).scalars().all()
|
||||
assert len(new_tasks) == before_tasks
|
||||
|
||||
# Original unchanged
|
||||
orig = (await db_session.execute(select(Recipe).where(Recipe.id == src_id))).scalar_one()
|
||||
assert orig.code == "REC-001" and orig.name == "Test Recipe"
|
||||
orig_versions = (await db_session.execute(
|
||||
select(func.count(RecipeVersion.id)).where(RecipeVersion.recipe_id == src_id)
|
||||
)).scalar_one()
|
||||
assert orig_versions == 1
|
||||
|
||||
async def test_duplicate_code_conflict(
|
||||
self, client: AsyncClient, maker_user: User, db_session: AsyncSession
|
||||
):
|
||||
src = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.post(
|
||||
f"/api/recipes/{src.id}/duplicate",
|
||||
json={"code": "REC-001", "name": "X"},
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_duplicate_missing_recipe(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/recipes/99999/duplicate",
|
||||
json={"code": "NEW", "name": "X"},
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Report PDF veri su Windows (WeasyPrint + DLL GTK + Kaleido con Edge/Chrome), offline.
|
||||
|
||||
Pacchetto Windows (TieMeasureFlow#78): senza Internet i report devono uscire lo stesso.
|
||||
Il test gira solo su Windows, con le DLL di packaging/windows/pdf/prendi-gtk.ps1 e un browser
|
||||
Chromium (Edge o Chrome) installati; altrimenti viene saltato.
|
||||
|
||||
Il conftest dei test sostituisce WeasyPrint con un mock: qui serve quello vero, quindi la
|
||||
generazione gira in un sottoprocesso che importa weasyprint PRIMA del conftest (il mock scatta
|
||||
solo se weasyprint non e' gia' in sys.modules).
|
||||
|
||||
Come preparare le DLL: powershell -File packaging/windows/pdf/prendi-gtk.ps1 -Destinazione packaging/windows/pdf/gtk
|
||||
(oppure WEASYPRINT_DLL_DIRECTORIES gia' impostata su una cartella con le DLL).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
GTK_DEFAULT = REPO / "packaging" / "windows" / "pdf" / "gtk"
|
||||
|
||||
# Browser Chromium cercati in ordine (Edge e' presente su ogni Windows 10/11).
|
||||
_BROWSER_CANDIDATI = [
|
||||
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
||||
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
||||
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||
]
|
||||
|
||||
|
||||
def _cartella_gtk() -> Path | None:
|
||||
"""Cartella DLL: WEASYPRINT_DLL_DIRECTORIES (primo elemento) o quella di prendi-gtk.ps1."""
|
||||
env = os.environ.get("WEASYPRINT_DLL_DIRECTORIES", "").split(";")[0]
|
||||
for c in (Path(env) if env else None, GTK_DEFAULT):
|
||||
if c and (c / "libpango-1.0-0.dll").exists():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _browser() -> str | None:
|
||||
env = os.environ.get("BROWSER_PATH")
|
||||
if env and Path(env).exists():
|
||||
return env
|
||||
return next((b for b in _BROWSER_CANDIDATI if Path(b).exists()), None)
|
||||
|
||||
|
||||
def _tz_ok() -> bool:
|
||||
"""Windows non ha il database IANA: serve il pacchetto tzdata (to_local usa ZoneInfo)."""
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
ZoneInfo("Europe/Rome")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(sys.platform != "win32", reason="solo Windows"),
|
||||
pytest.mark.skipif(_cartella_gtk() is None, reason="DLL GTK assenti: lancia prendi-gtk.ps1"),
|
||||
pytest.mark.skipif(_browser() is None, reason="nessun Edge/Chrome installato"),
|
||||
# Senza tzdata i report falliscono su Windows (ZoneInfoNotFoundError): il pacchetto deve dichiararlo.
|
||||
pytest.mark.skipif(not _tz_ok(), reason="manca tzdata: aggiungere 'tzdata; sys_platform == \"win32\"' a pyproject"),
|
||||
]
|
||||
|
||||
# Sottoprocesso: dati veri nel DB SQLite in memoria del conftest, poi i due report reali.
|
||||
_WORKER = r"""
|
||||
import asyncio, json, sys
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
import weasyprint # VERO, prima del conftest (che altrimenti lo sostituisce con un mock)
|
||||
assert not type(weasyprint.HTML).__module__.startswith("unittest"), "WeasyPrint mockato"
|
||||
from datetime import datetime, timedelta
|
||||
from src.backend.tests.conftest import Base, TestSessionFactory, test_engine, _create_user, create_test_recipe
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
from src.backend.services import report_service
|
||||
from sqlalchemy import select
|
||||
|
||||
VALORI = [10.02, 9.98, 10.05, 10.11, 9.94, 10.0, 10.07, 9.96, 10.03, 10.09, 9.99, 10.04, 10.38, 10.01, 9.97]
|
||||
|
||||
async def main():
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with TestSessionFactory() as db:
|
||||
user = await _create_user(db, "metrologo", roles=["Metrologist"])
|
||||
recipe = await create_test_recipe(db, user.id)
|
||||
version = recipe.versions[0]
|
||||
sub = (await db.execute(select(RecipeSubtask))).scalars().first()
|
||||
t0 = datetime.utcnow() - timedelta(hours=1)
|
||||
for i, v in enumerate(VALORI):
|
||||
pf = "warning" if v > 10.3 else "pass"
|
||||
db.add(Measurement(subtask_id=sub.id, version_id=version.id, measured_by=user.id,
|
||||
value=v, pass_fail=pf, measured_at=t0 + timedelta(minutes=i)))
|
||||
await db.commit()
|
||||
spc = await report_service.generate_spc_report(db, recipe.id, sub.id)
|
||||
mis = await report_service.generate_measurement_report(db, recipe.id)
|
||||
open(sys.argv[2] + "/spc.pdf", "wb").write(spc)
|
||||
open(sys.argv[2] + "/misure.pdf", "wb").write(mis)
|
||||
# MathJax locale: senza, Kaleido chiede lo script al CDN e senza Internet ogni grafico attende ~19 s.
|
||||
from pathlib import Path
|
||||
mj = report_service.pio.defaults.mathjax
|
||||
print(json.dumps({"ok": True, "mathjax_locale": bool(mj) and Path(mj).is_file()}))
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pdf_generati(tmp_path_factory):
|
||||
"""Genera i due PDF in un sottoprocesso con proxy finto: se serve Internet, fallisce."""
|
||||
out = tmp_path_factory.mktemp("report_win")
|
||||
env = dict(os.environ)
|
||||
env.update(
|
||||
WEASYPRINT_DLL_DIRECTORIES=str(_cartella_gtk()),
|
||||
BROWSER_PATH=_browser(),
|
||||
# Proxy morto per la parte Python (Chrome/Edge usano invece il proxy di sistema: vedi LEGGIMI.md).
|
||||
HTTP_PROXY="http://127.0.0.1:9", HTTPS_PROXY="http://127.0.0.1:9",
|
||||
PYTHONIOENCODING="utf-8",
|
||||
)
|
||||
# Il sottoprocesso non deve leggere il .env di sviluppo ne' un DB reale.
|
||||
env.pop("DB_URL", None)
|
||||
res = subprocess.run(
|
||||
[sys.executable, "-W", "ignore", "-c", _WORKER, str(REPO), str(out)],
|
||||
cwd=REPO, env=env, capture_output=True, text=True, timeout=180,
|
||||
)
|
||||
assert res.returncode == 0, f"sottoprocesso fallito:\n{res.stdout}\n{res.stderr}"
|
||||
esito = json.loads(res.stdout.strip().splitlines()[-1])
|
||||
assert esito["ok"] is True
|
||||
assert esito["mathjax_locale"], "Kaleido userebbe MathJax dal CDN: niente rete = report lenti"
|
||||
return out
|
||||
|
||||
|
||||
def _pdf(path: Path):
|
||||
import pdfplumber
|
||||
|
||||
return pdfplumber.open(path)
|
||||
|
||||
|
||||
def test_spc_ha_carta_di_controllo_e_istogramma(pdf_generati):
|
||||
"""Il report SPC e' un PDF vero con una pagina almeno e i due grafici disegnati."""
|
||||
path = pdf_generati / "spc.pdf"
|
||||
assert path.read_bytes()[:5] == b"%PDF-"
|
||||
with _pdf(path) as pdf:
|
||||
assert len(pdf.pages) >= 1
|
||||
testo = "\n".join(p.extract_text() or "" for p in pdf.pages)
|
||||
# I titoli stanno dentro l'SVG di Plotly: se ci sono, Kaleido ha reso e WeasyPrint ha disegnato.
|
||||
assert "Control Chart" in testo
|
||||
assert "Histogram" in testo
|
||||
# Grafico presente = segni vettoriali (tracce, assi, barre) oltre al testo.
|
||||
segni = sum(len(p.curves) + len(p.lines) + len(p.rects) for p in pdf.pages)
|
||||
assert segni > 40, f"troppo pochi segni vettoriali ({segni}): grafico assente?"
|
||||
|
||||
|
||||
def test_report_misure_e_pdf_vero(pdf_generati):
|
||||
"""Anche il report a tabella (solo WeasyPrint, niente Kaleido) esce con le righe misurate."""
|
||||
path = pdf_generati / "misure.pdf"
|
||||
assert path.read_bytes()[:5] == b"%PDF-"
|
||||
with _pdf(path) as pdf:
|
||||
assert len(pdf.pages) >= 1
|
||||
testo = "\n".join(p.extract_text() or "" for p in pdf.pages)
|
||||
assert "10.3800" in testo # il valore anomalo e' in tabella
|
||||
@@ -176,6 +176,50 @@ class TestUpdateSettingsValidation:
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# --- #47/#48/#49: chiavi Impostazioni di Tràfilo ---
|
||||
|
||||
@pytest.mark.parametrize("key,value", [
|
||||
("default_measurement_interval_minutes", "1"),
|
||||
("default_measurement_interval_minutes", "1440"),
|
||||
("default_measurement_interval_minutes", ""),
|
||||
("general_tolerance_plus", "0"),
|
||||
("general_tolerance_plus", "0.05"),
|
||||
("general_tolerance_minus", ""),
|
||||
("list_measurement_types", '["Lunghezza", "Diametro"]'),
|
||||
("list_units", "[]"),
|
||||
("list_units", ""),
|
||||
])
|
||||
async def test_new_keys_valid_accepted(
|
||||
self, client: AsyncClient, admin_user: User, key: str, value: str
|
||||
):
|
||||
resp = await client.put(
|
||||
"/api/settings/", headers=auth_headers(admin_user), json={key: value}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
@pytest.mark.parametrize("key,value", [
|
||||
("default_measurement_interval_minutes", "0"),
|
||||
("default_measurement_interval_minutes", "1441"),
|
||||
("default_measurement_interval_minutes", "abc"),
|
||||
("general_tolerance_plus", "-0.1"),
|
||||
("general_tolerance_plus", "0,05"),
|
||||
("general_tolerance_minus", "NaN"),
|
||||
("general_tolerance_minus", "abc"),
|
||||
("list_measurement_types", "not json"),
|
||||
("list_measurement_types", '{"a": 1}'),
|
||||
("list_units", '["mm", ""]'),
|
||||
("list_units", '["mm", 3]'),
|
||||
("list_units", "[" + ",".join(f'"v{i}"' for i in range(51)) + "]"),
|
||||
])
|
||||
async def test_new_keys_invalid_rejected(
|
||||
self, client: AsyncClient, admin_user: User, key: str, value: str
|
||||
):
|
||||
resp = await client.put(
|
||||
"/api/settings/", headers=auth_headers(admin_user), json={key: value}
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
assert key in resp.json()["detail"]
|
||||
|
||||
async def test_unknown_key_not_validated(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""init-db / reset-db con _stamp_head REALE su un file SQLite (TieMeasureFlow#80).
|
||||
|
||||
_stamp_head esegue migrations/env.py, che chiama asyncio.run(): dentro il loop
|
||||
gia' attivo di FastAPI dava RuntimeError -> 500. test_setup.py e
|
||||
test_station_seed.py sostituiscono _stamp_head con un no-op e non lo vedevano:
|
||||
qui NON si fa monkeypatch di _stamp_head.
|
||||
"""
|
||||
import logging.config
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.main import app
|
||||
|
||||
SETUP_PWD = "test-setup-password"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file_db(tmp_path, monkeypatch):
|
||||
"""Punta settings, engine e session factory del router setup a un file SQLite."""
|
||||
import src.backend.api.routers.setup as setup_mod
|
||||
|
||||
db_file = tmp_path / "t.db"
|
||||
url = f"sqlite+aiosqlite:///{db_file.as_posix()}"
|
||||
# env.py ricava sqlalchemy.url da settings.database_url (property su db_url)
|
||||
monkeypatch.setattr(settings, "db_url", url)
|
||||
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
|
||||
file_engine = create_async_engine(url, echo=False)
|
||||
monkeypatch.setattr(setup_mod, "engine", file_engine)
|
||||
monkeypatch.setattr(
|
||||
setup_mod, "async_session_factory",
|
||||
async_sessionmaker(file_engine, expire_on_commit=False),
|
||||
)
|
||||
# env.py chiama fileConfig, che disattiverebbe i logger degli altri test
|
||||
monkeypatch.setattr(logging.config, "fileConfig", lambda *a, **k: None)
|
||||
return db_file, setup_mod, file_engine
|
||||
|
||||
|
||||
def _head() -> str:
|
||||
from src.backend.api.routers.setup import _alembic_ini
|
||||
cfg = Config(str(_alembic_ini))
|
||||
return ScriptDirectory.from_config(cfg).get_current_head()
|
||||
|
||||
|
||||
def _versions(db_file) -> list[str]:
|
||||
con = sqlite3.connect(db_file)
|
||||
try:
|
||||
return [r[0] for r in con.execute("SELECT version_num FROM alembic_version")]
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def http():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_db_stamps_head(file_db, http):
|
||||
db_file, _, file_engine = file_db
|
||||
resp = await http.post("/api/setup/init-db", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _versions(db_file) == [_head()]
|
||||
await file_engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_db_stamps_head(file_db, http):
|
||||
db_file, _, file_engine = file_db
|
||||
resp = await http.post("/api/setup/init-db", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = await http.post("/api/setup/reset-db", json={"password": SETUP_PWD, "confirm": True})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _versions(db_file) == [_head()]
|
||||
await file_engine.dispose()
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Backend su un file SQLite locale (pacchetto Windows, TieMeasureFlow#77).
|
||||
|
||||
Il modulo `database` costruisce l'engine all'import leggendo l'ambiente, quindi
|
||||
i test che riguardano DB_URL girano in un sottoprocesso con DB_URL/UPLOAD_DIR
|
||||
impostati: e' lo stesso percorso di avvio.py nel pacchetto, senza toccare
|
||||
l'engine in memoria usato dal resto della suite.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from src.backend.config import Settings
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
# Script eseguito nel sottoprocesso: upgrade head da DB vuoto, poi app reale
|
||||
# (health, login con utente creato via ORM), PRAGMA e vincolo FK.
|
||||
_SCRIPT = textwrap.dedent(
|
||||
"""
|
||||
import asyncio, json, sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
# WeasyPrint senza GTK: stesso mock di conftest.py (le DLL sono affare del piano B)
|
||||
sys.modules.setdefault("weasyprint", MagicMock())
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
from src.backend.config import settings
|
||||
ini = Path(r"{ini}")
|
||||
command.upgrade(Config(str(ini)), "head")
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from src.backend.database import async_session_factory, engine
|
||||
from src.backend.main import app
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.services.auth_service import hash_password
|
||||
|
||||
async def main():
|
||||
out = {{}}
|
||||
async with async_session_factory() as s:
|
||||
s.add(User(username="fiera", password_hash=hash_password("Pass12345"),
|
||||
display_name="Fiera", roles=["Maker"], is_admin=True))
|
||||
await s.commit()
|
||||
async with async_session_factory() as s:
|
||||
out["utente"] = (await s.execute(select(User.username))).scalars().all()
|
||||
out["fk"] = (await s.execute(text("PRAGMA foreign_keys"))).scalar()
|
||||
out["journal"] = (await s.execute(text("PRAGMA journal_mode"))).scalar()
|
||||
out["busy"] = (await s.execute(text("PRAGMA busy_timeout"))).scalar()
|
||||
# FK attive: una versione con recipe_id inesistente deve essere rifiutata
|
||||
try:
|
||||
async with async_session_factory() as s:
|
||||
s.add(RecipeVersion(recipe_id=99999, version_number=1, created_by=1))
|
||||
await s.commit()
|
||||
out["fk_violation"] = False
|
||||
except IntegrityError:
|
||||
out["fk_violation"] = True
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
|
||||
out["health"] = (await c.get("/api/health")).status_code
|
||||
r = await c.post("/api/auth/login", json={{"username": "fiera", "password": "Pass12345"}})
|
||||
out["login"] = r.status_code
|
||||
out["upload_path"] = str(settings.upload_path)
|
||||
out["database_url"] = settings.database_url
|
||||
await engine.dispose()
|
||||
print("RISULTATO=" + json.dumps(out))
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _run(tmp_path: Path, extra_env: dict[str, str]) -> dict:
|
||||
ini = _ROOT / "src" / "backend" / "migrations" / "alembic.ini"
|
||||
env = {k: v for k, v in os.environ.items() if k not in ("DB_URL", "UPLOAD_DIR")}
|
||||
env.update(extra_env)
|
||||
env["PYTHONPATH"] = str(_ROOT)
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", _SCRIPT.format(ini=str(ini))],
|
||||
cwd=str(_ROOT), env=env, capture_output=True, text=True, timeout=180,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr[-3000:]
|
||||
line = [l for l in proc.stdout.splitlines() if l.startswith("RISULTATO=")][-1]
|
||||
return json.loads(line[len("RISULTATO="):])
|
||||
|
||||
|
||||
def test_file_sqlite_upgrade_app_pragma_e_upload_assoluto(tmp_path):
|
||||
db = tmp_path / "tmf.db"
|
||||
uploads = tmp_path / "uploads"
|
||||
# barre "/" nell'URL come nel pacchetto Windows (D:/... non va in escape)
|
||||
url = f"sqlite+aiosqlite:///{db.as_posix()}"
|
||||
out = _run(tmp_path, {"DB_URL": url, "UPLOAD_DIR": str(uploads)})
|
||||
|
||||
assert out["database_url"] == url
|
||||
assert db.exists() # il file e' stato creato da upgrade head
|
||||
assert out["utente"] == ["fiera"]
|
||||
assert out["health"] == 200
|
||||
assert out["login"] == 200
|
||||
assert out["fk"] == 1 # PRAGMA foreign_keys attivo
|
||||
assert out["fk_violation"] is True
|
||||
assert out["journal"] == "wal"
|
||||
assert out["busy"] == 5000
|
||||
# UPLOAD_DIR assoluto rispettato (non accodato alla radice del progetto)
|
||||
assert Path(out["upload_path"]) == uploads
|
||||
|
||||
|
||||
def test_database_url_senza_db_url_resta_mysql(monkeypatch):
|
||||
monkeypatch.delenv("DB_URL", raising=False)
|
||||
s = Settings(_env_file=None, db_url=None)
|
||||
assert s.database_url.startswith("mysql+asyncmy://")
|
||||
|
||||
|
||||
def test_db_url_vince_sulle_parti_db(monkeypatch):
|
||||
s = Settings(_env_file=None, db_url="sqlite+aiosqlite:///C:/x/tmf.db", db_host="altro")
|
||||
assert s.database_url == "sqlite+aiosqlite:///C:/x/tmf.db"
|
||||
|
||||
|
||||
def test_produzione_con_sqlite_non_richiede_db_password():
|
||||
# niente RuntimeError: con SQLite la password DB di default non conta
|
||||
Settings(
|
||||
_env_file=None, app_env="production", server_secret_key="x" * 40,
|
||||
db_url="sqlite+aiosqlite:///C:/x/tmf.db",
|
||||
)
|
||||
|
||||
|
||||
def test_produzione_mysql_rifiuta_ancora_password_di_default():
|
||||
import pytest
|
||||
with pytest.raises(Exception, match="DB_PASSWORD"):
|
||||
Settings(_env_file=None, app_env="production", server_secret_key="x" * 40)
|
||||
|
||||
|
||||
def test_upload_dir_relativo_resta_sotto_la_radice():
|
||||
s = Settings(_env_file=None, upload_dir="uploads")
|
||||
assert s.upload_path == _ROOT / "uploads"
|
||||
|
||||
|
||||
def test_upload_dir_assoluto(tmp_path):
|
||||
s = Settings(_env_file=None, upload_dir=str(tmp_path / "u"))
|
||||
assert s.upload_path == tmp_path / "u"
|
||||
@@ -374,3 +374,159 @@ async def test_reset_unknown_station_is_404(client: AsyncClient, admin_user):
|
||||
"/api/stations/999999/recipes", headers=auth_headers(admin_user),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- vision_worker_url (#45) ---
|
||||
|
||||
async def test_station_vision_worker_url_roundtrip(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"code": "ST-VW", "name": "VW", "vision_worker_url": " http://10.0.0.7:8100/ "},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
# spazi tolti e "/" finale tolto
|
||||
assert created.json()["vision_worker_url"] == "http://10.0.0.7:8100"
|
||||
|
||||
sid = created.json()["id"]
|
||||
updated = await client.put(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
json={"vision_worker_url": "https://w.example:9000"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["vision_worker_url"] == "https://w.example:9000"
|
||||
|
||||
# stringa vuota = torna al worker centrale
|
||||
cleared = await client.put(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
json={"vision_worker_url": ""},
|
||||
)
|
||||
assert cleared.status_code == 200
|
||||
assert cleared.json()["vision_worker_url"] is None
|
||||
|
||||
|
||||
async def test_station_vision_worker_url_defaults_to_null(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-VN", "name": "VN"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert created.json()["vision_worker_url"] is None
|
||||
|
||||
|
||||
async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin_user):
|
||||
resp = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-VF", "name": "VF", "vision_worker_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-VG", "name": "VG"},
|
||||
)
|
||||
resp = await client.put(
|
||||
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
|
||||
json={"vision_worker_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# --- camera_url / camera_source (#72) ---
|
||||
|
||||
async def test_station_camera_fields_roundtrip(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={
|
||||
"code": "ST-CAM", "name": "CAM",
|
||||
"camera_url": " http://127.0.0.1:8765/ ",
|
||||
"camera_source": " camera:folder ",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
assert created.json()["camera_url"] == "http://127.0.0.1:8765"
|
||||
assert created.json()["camera_source"] == "camera:folder"
|
||||
|
||||
sid = created.json()["id"]
|
||||
read = await client.get(f"/api/stations/{sid}", headers=auth_headers(admin_user))
|
||||
assert read.status_code == 200
|
||||
assert read.json()["camera_url"] == "http://127.0.0.1:8765"
|
||||
assert read.json()["camera_source"] == "camera:folder"
|
||||
|
||||
updated = await client.put(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
json={"camera_url": "https://cam.example:9000", "camera_source": "camera:ABC123"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["camera_url"] == "https://cam.example:9000"
|
||||
assert updated.json()["camera_source"] == "camera:ABC123"
|
||||
|
||||
cleared = await client.put(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
json={"camera_url": "", "camera_source": " "},
|
||||
)
|
||||
assert cleared.status_code == 200
|
||||
assert cleared.json()["camera_url"] is None
|
||||
assert cleared.json()["camera_source"] is None
|
||||
|
||||
|
||||
async def test_station_camera_fields_default_to_null(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-CN", "name": "CN"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert created.json()["camera_url"] is None
|
||||
assert created.json()["camera_source"] is None
|
||||
|
||||
|
||||
async def test_station_camera_url_must_be_http(client: AsyncClient, admin_user):
|
||||
resp = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-CF", "name": "CF", "camera_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-CG", "name": "CG"},
|
||||
)
|
||||
resp = await client.put(
|
||||
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
|
||||
json={"camera_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_station_by_code_returns_station(client: AsyncClient, admin_user):
|
||||
await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-BC", "name": "BC", "camera_url": "http://h:1", "camera_source": "camera:folder"},
|
||||
)
|
||||
resp = await client.get("/api/stations/by-code/ST-BC", headers=auth_headers(admin_user))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["code"] == "ST-BC"
|
||||
assert resp.json()["camera_url"] == "http://h:1"
|
||||
assert resp.json()["camera_source"] == "camera:folder"
|
||||
|
||||
|
||||
async def test_station_by_code_unknown_is_404(client: AsyncClient, admin_user):
|
||||
resp = await client.get("/api/stations/by-code/NOPE", headers=auth_headers(admin_user))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_station_by_code_any_authenticated_user(
|
||||
client: AsyncClient, measurement_tec_user, admin_user,
|
||||
):
|
||||
await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-BA", "name": "BA"},
|
||||
)
|
||||
resp = await client.get("/api/stations/by-code/ST-BA", headers=auth_headers(measurement_tec_user))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["code"] == "ST-BA"
|
||||
|
||||
|
||||
async def test_station_by_code_requires_auth(client: AsyncClient):
|
||||
resp = await client.get("/api/stations/by-code/ST-BA")
|
||||
assert resp.status_code == 401
|
||||
|
||||
@@ -211,3 +211,192 @@ class TestDateFilter:
|
||||
assert resp.status_code == 200
|
||||
# All measurements should be within this wide range
|
||||
assert resp.json()["total"] >= 2
|
||||
|
||||
|
||||
class TestRunFilter:
|
||||
"""#58: statistiche per sessione di produzione."""
|
||||
|
||||
async def _setup(self, db_session, user, metro):
|
||||
from datetime import datetime
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from sqlalchemy import select
|
||||
recipe = await create_test_recipe(db_session, user.id)
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().first()
|
||||
station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
runs = []
|
||||
for i, lot in enumerate(["L1", "L2"]):
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=user.id, status="closed", lot_number=lot,
|
||||
started_at=datetime(2026, 1, 1 + i),
|
||||
)
|
||||
db_session.add(run)
|
||||
runs.append(run)
|
||||
await db_session.commit()
|
||||
return recipe, runs
|
||||
|
||||
async def test_filter_by_run_and_unfiltered(
|
||||
self, client, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user)
|
||||
subtask_id, version_id = await _seed_measurements(
|
||||
client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2],
|
||||
)
|
||||
# Assegna le prime due misure al run 1, la terza al run 2.
|
||||
from sqlalchemy import select
|
||||
ms = (await db_session.execute(
|
||||
select(Measurement).order_by(Measurement.id)
|
||||
)).scalars().all()
|
||||
ms[0].production_run_id = runs[0].id
|
||||
ms[1].production_run_id = runs[0].id
|
||||
ms[2].production_run_id = runs[1].id
|
||||
await db_session.commit()
|
||||
|
||||
h = auth_headers(metrologist_user)
|
||||
r1 = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id, "production_run_id": runs[0].id})
|
||||
assert r1.json()["total"] == 2
|
||||
r2 = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id, "production_run_id": runs[1].id})
|
||||
assert r2.json()["total"] == 1
|
||||
r_all = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id})
|
||||
assert r_all.json()["total"] == 3
|
||||
|
||||
runs_resp = await client.get("/api/statistics/runs", headers=h,
|
||||
params={"recipe_id": recipe.id})
|
||||
assert runs_resp.status_code == 200
|
||||
data = runs_resp.json()
|
||||
assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima
|
||||
assert [r["measurement_count"] for r in data] == [1, 2]
|
||||
assert data[0]["lot_number"] == "L2"
|
||||
assert data[0]["station_name"] == "Stazione 58"
|
||||
assert data[0]["closed_at"] is None
|
||||
|
||||
async def test_runs_require_metrologist(
|
||||
self, client, measurement_tec_user, db_session,
|
||||
):
|
||||
resp = await client.get("/api/statistics/runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
params={"recipe_id": 1})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestRunTaskResults:
|
||||
"""#58: GET /api/statistics/runs/{id}/task-results."""
|
||||
|
||||
FIELDS = [
|
||||
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
|
||||
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||
]
|
||||
|
||||
async def _setup(self, client, db_session, maker_user, tec_user):
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task_result import TaskResult
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TR58")
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().first()
|
||||
db_session.add(Station(code="ST-TR58", name="S", active=True, created_by=maker_user.id))
|
||||
await db_session.commit()
|
||||
station = (await db_session.execute(select(Station))).scalars().first()
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=tec_user.id, status="open", started_at=datetime(2026, 1, 1),
|
||||
)
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
h = auth_headers(maker_user)
|
||||
check = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||
json={"title": "Controllo", "task_type": "check"},
|
||||
)).json()
|
||||
note = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||
json={"title": "Nota", "task_type": "note", "fields": self.FIELDS},
|
||||
)).json()
|
||||
# Inseriti fuori ordine: l'API deve ordinare per created_at.
|
||||
rows = [
|
||||
TaskResult(task_id=note["id"], version_id=version.id, production_run_id=run.id,
|
||||
user_id=tec_user.id, outcome="done", note="ok finito",
|
||||
values={"peso": "12", "foto": "x.jpg"},
|
||||
attachments=[{"key": "foto", "path": "task_results/a.jpg", "name": "a.jpg"}],
|
||||
created_at=datetime(2026, 1, 1, 10, 3)),
|
||||
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
|
||||
user_id=tec_user.id, outcome="nok", note="graffio",
|
||||
created_at=datetime(2026, 1, 1, 10, 2)),
|
||||
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
|
||||
user_id=tec_user.id, outcome="ok",
|
||||
created_at=datetime(2026, 1, 1, 10, 1)),
|
||||
# Un esito di un'altra sessione (nessuna) non deve comparire.
|
||||
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=None,
|
||||
user_id=tec_user.id, outcome="ok",
|
||||
created_at=datetime(2026, 1, 1, 9, 0)),
|
||||
]
|
||||
db_session.add_all(rows)
|
||||
await db_session.commit()
|
||||
return recipe, run, check, note
|
||||
|
||||
async def test_counts_order_labels_attachments(
|
||||
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
_, run, check, note = await self._setup(
|
||||
client, db_session, maker_user, measurement_tec_user)
|
||||
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
|
||||
headers=auth_headers(metrologist_user))
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["run_id"] == run.id
|
||||
assert data["counts"] == {"ok": 1, "nok": 1, "done": 1}
|
||||
res = data["results"]
|
||||
assert [r["outcome"] for r in res] == ["ok", "nok", "done"] # per ora crescente
|
||||
assert res[1]["note"] == "graffio"
|
||||
assert res[1]["task_title"] == "Controllo"
|
||||
assert res[1]["task_type"] == "check"
|
||||
assert res[1]["user_name"] == measurement_tec_user.display_name
|
||||
assert res[1]["values"] == [] and res[1]["attachments"] == []
|
||||
n = res[2]
|
||||
assert n["task_title"] == "Nota" and n["note"] == "ok finito"
|
||||
assert n["values"] == [
|
||||
{"key": "peso", "label": "Peso", "value": "12"},
|
||||
{"key": "foto", "label": "Foto", "value": "x.jpg"},
|
||||
]
|
||||
assert n["attachments"] == [
|
||||
{"key": "foto", "label": "Foto", "name": "a.jpg", "path": "task_results/a.jpg"},
|
||||
]
|
||||
|
||||
async def test_label_falls_back_to_key_when_field_removed(
|
||||
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
_, run, _, note = await self._setup(
|
||||
client, db_session, maker_user, measurement_tec_user)
|
||||
task = await db_session.get(RecipeTask, note["id"])
|
||||
task.fields = [self.FIELDS[0]] # il campo «foto» viene tolto dal task
|
||||
await db_session.commit()
|
||||
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
|
||||
headers=auth_headers(metrologist_user))
|
||||
n = resp.json()["results"][2]
|
||||
assert {v["key"]: v["label"] for v in n["values"]} == {"peso": "Peso", "foto": "foto"}
|
||||
assert n["attachments"][0]["label"] == "foto"
|
||||
|
||||
async def test_unknown_run_is_404(self, client, metrologist_user):
|
||||
resp = await client.get("/api/statistics/runs/99999/task-results",
|
||||
headers=auth_headers(metrologist_user))
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_measurement_tec_is_403(self, client, measurement_tec_user):
|
||||
resp = await client.get("/api/statistics/runs/1/task-results",
|
||||
headers=auth_headers(measurement_tec_user))
|
||||
assert resp.status_code == 403
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Test di in_loop / at_close / fields e del tipo 'check' sui task (#53 #54 #55),
|
||||
di close_task_ids nella risposta del run e del bucket di rate limit del login PIN."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
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.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
FIELDS = [
|
||||
{"key": "peso", "label": "Peso", "type": "number", "default": "1.5", "attachment": False},
|
||||
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||
]
|
||||
|
||||
|
||||
async def _post_task(client, user, recipe_id, **body):
|
||||
return await client.post(
|
||||
f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user), json=body,
|
||||
)
|
||||
|
||||
|
||||
async def test_create_defaults_in_loop_from_type(client: AsyncClient, maker_user, db_session):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L1")
|
||||
await db_session.commit()
|
||||
expected = {"note": False, "drawing": False, "measure": True,
|
||||
"camera_measure": True, "dxf_compare": True, "check": True}
|
||||
for task_type, in_loop in expected.items():
|
||||
resp = await _post_task(client, maker_user, recipe.id, title=task_type, task_type=task_type)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["in_loop"] is in_loop, task_type
|
||||
assert body["at_close"] is False
|
||||
assert body["fields"] is None
|
||||
|
||||
|
||||
async def test_create_explicit_in_loop_and_note_fields(client: AsyncClient, maker_user, db_session):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L2")
|
||||
await db_session.commit()
|
||||
resp = await _post_task(
|
||||
client, maker_user, recipe.id, title="Nota", task_type="note",
|
||||
in_loop=True, at_close=True, fields=FIELDS,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["in_loop"] is True and body["at_close"] is True
|
||||
assert body["fields"] == FIELDS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task_type", ["measure", "check", "drawing"])
|
||||
async def test_create_at_close_or_fields_rejected_for_other_types(
|
||||
client: AsyncClient, maker_user, db_session, task_type,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=f"REC-L3{task_type}")
|
||||
await db_session.commit()
|
||||
resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, at_close=True)
|
||||
assert resp.status_code == 422
|
||||
assert isinstance(resp.json()["detail"], str)
|
||||
resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, fields=FIELDS)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
[{"key": "A b", "label": "x", "type": "text"}], # chiave non slug
|
||||
[{"key": "k", "label": "", "type": "text"}], # etichetta vuota
|
||||
[{"key": "k", "label": "x", "type": "date"}], # tipo ignoto
|
||||
[{"key": "k", "label": "x", "type": "text"}, {"key": "k", "label": "y", "type": "text"}], # duplicata
|
||||
[{"key": "k", "label": "x", "type": "number", "default": "abc"}], # default non numerico
|
||||
[{"key": f"k{i}", "label": "x", "type": "text"} for i in range(21)], # troppi
|
||||
])
|
||||
async def test_create_invalid_fields_rejected(client: AsyncClient, maker_user, db_session, bad):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L4")
|
||||
await db_session.commit()
|
||||
resp = await _post_task(client, maker_user, recipe.id, title="n", task_type="note", fields=bad)
|
||||
assert resp.status_code == 422
|
||||
assert isinstance(resp.json()["detail"], str)
|
||||
|
||||
|
||||
async def test_update_fields_null_clears_and_type_rules(client: AsyncClient, maker_user, db_session):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L5")
|
||||
await db_session.commit()
|
||||
created = (await _post_task(
|
||||
client, maker_user, recipe.id, title="Nota", task_type="note",
|
||||
at_close=True, fields=FIELDS,
|
||||
)).json()
|
||||
headers = auth_headers(maker_user)
|
||||
|
||||
# il frontend manda sempre i tre campi: fields null = nessun campo
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers,
|
||||
json={"in_loop": True, "at_close": False, "fields": None},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["in_loop"] is True and body["at_close"] is False and body["fields"] is None
|
||||
|
||||
# tipo diverso da note con at_close o fields: 422
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers,
|
||||
json={"task_type": "measure", "at_close": True, "fields": None},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers,
|
||||
json={"task_type": "check", "at_close": False, "fields": FIELDS},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
# un task che ha gia' at_close non puo' passare a measure senza toglierlo
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers,
|
||||
json={"at_close": True, "fields": FIELDS},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers, json={"task_type": "measure"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_copy_on_write_carries_the_three_fields(
|
||||
client: AsyncClient, maker_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L6")
|
||||
await db_session.commit()
|
||||
created = (await _post_task(
|
||||
client, maker_user, recipe.id, title="Nota", task_type="note",
|
||||
in_loop=True, at_close=True, fields=FIELDS,
|
||||
)).json()
|
||||
|
||||
# una misura sulla versione corrente forza il copy-on-write alla prossima modifica
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)).scalar_one()
|
||||
subtask_id = (await db_session.execute(
|
||||
select(RecipeSubtask.id).join(RecipeSubtask.task)
|
||||
.where(RecipeSubtask.task.has(version_id=version.id))
|
||||
)).scalars().first()
|
||||
db_session.add(Measurement(
|
||||
subtask_id=subtask_id, version_id=version.id, measured_by=maker_user.id,
|
||||
value=10.0, pass_fail="pass", deviation=0.0, input_method="manual",
|
||||
))
|
||||
await db_session.commit()
|
||||
# sessione condivisa col client: la collezione version.tasks va riletta
|
||||
db_session.expire_all()
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=auth_headers(maker_user),
|
||||
json={"title": "Nota rinominata"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["version_id"] != version.id # e' la copia nella nuova versione
|
||||
assert body["in_loop"] is True and body["at_close"] is True
|
||||
assert body["fields"] == FIELDS
|
||||
|
||||
|
||||
async def test_run_response_loop_and_close_task_ids(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L7")
|
||||
db_session.add(Station(code="ST-L7", name="S", active=True, created_by=admin_user.id))
|
||||
await db_session.commit()
|
||||
|
||||
async def mk(**body):
|
||||
resp = await _post_task(client, maker_user, recipe.id, **body)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
check = await mk(title="Check", task_type="check")
|
||||
note_loop = await mk(title="Nota ciclo", task_type="note", in_loop=True)
|
||||
note_out = await mk(title="Nota fuori", task_type="note")
|
||||
close_a = await mk(title="Chiusura A", task_type="note", at_close=True)
|
||||
measure_off = await mk(title="Misura fuori", task_type="measure", in_loop=False)
|
||||
close_b = await mk(title="Chiusura B", task_type="note", at_close=True)
|
||||
|
||||
first_id = (await db_session.execute(
|
||||
select(RecipeTask.id).where(RecipeTask.version_id == check["version_id"])
|
||||
.order_by(RecipeTask.order_index)
|
||||
)).scalars().first()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/production-runs", headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": "ST-L7", "recipe_id": recipe.id},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
# ciclo: solo in_loop, in ordine (misura della fixture, check, nota in ciclo)
|
||||
assert body["measurement_task_ids"] == [first_id, check["id"], note_loop["id"]]
|
||||
assert note_out["id"] not in body["measurement_task_ids"]
|
||||
assert measure_off["id"] not in body["measurement_task_ids"]
|
||||
assert body["close_task_ids"] == [close_a["id"], close_b["id"]]
|
||||
|
||||
|
||||
class TestLoginPinRateLimitBucket:
|
||||
"""#59: il login con PIN sta nel bucket 'login' come il login con password."""
|
||||
|
||||
def test_login_pin_is_login_bucket(self):
|
||||
assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/auth/login-pin")
|
||||
|
||||
def test_login_pin_get_is_not_login_bucket(self):
|
||||
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/login-pin")
|
||||
|
||||
def test_pin_users_list_is_not_login_bucket(self):
|
||||
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/pin-users")
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Test di POST/GET /api/task-results (#54 check OK/NOK, #55 note con campi)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
FIELDS = [
|
||||
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
|
||||
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||
]
|
||||
|
||||
|
||||
async def _setup(client, db_session, maker_user, admin_user, code):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=code)
|
||||
db_session.add(Station(code=f"ST-{code}", name="S", active=True, created_by=admin_user.id))
|
||||
await db_session.commit()
|
||||
h = auth_headers(maker_user)
|
||||
check = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h, json={"title": "C", "task_type": "check"},
|
||||
)).json()
|
||||
note = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||
json={"title": "N", "task_type": "note", "fields": FIELDS},
|
||||
)).json()
|
||||
return recipe, check, note
|
||||
|
||||
|
||||
async def _open_run(client, user, recipe, code):
|
||||
resp = await client.post(
|
||||
"/api/production-runs", headers=auth_headers(user),
|
||||
json={"station_code": f"ST-{code}", "recipe_id": recipe.id},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _post(client, user, **body):
|
||||
return await client.post("/api/task-results", headers=auth_headers(user), json=body)
|
||||
|
||||
|
||||
async def test_requires_auth_and_role(client: AsyncClient, maker_user, admin_user, db_session):
|
||||
_, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR0")
|
||||
resp = await client.post("/api/task-results", json={"task_id": check["id"], "outcome": "ok"})
|
||||
assert resp.status_code == 401
|
||||
# un Maker senza ruolo di reparto non registra esiti
|
||||
resp = await _post(client, maker_user, task_id=check["id"], outcome="ok")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_check_ok_and_nok_rules(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR1")
|
||||
run = await _open_run(client, measurement_tec_user, recipe, "TR1")
|
||||
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
production_run_id=run["id"], outcome="ok")
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["outcome"] == "ok"
|
||||
assert body["version_id"] == check["version_id"]
|
||||
assert body["user_id"] == measurement_tec_user.id
|
||||
assert body["production_run_id"] == run["id"]
|
||||
assert body["created_at"]
|
||||
|
||||
for note in (None, "", " "):
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
outcome="nok", note=note)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"] == "Per un NOK serve la nota"
|
||||
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
outcome="nok", note=" graffio ")
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["note"] == "graffio"
|
||||
|
||||
# un check non si chiude con 'done'
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"], outcome="done")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_note_values_and_attachments(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, _, note = await _setup(client, db_session, maker_user, admin_user, "TR2")
|
||||
ok = await _post(
|
||||
client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
values={"peso": "12,5"},
|
||||
attachments=[{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}],
|
||||
)
|
||||
assert ok.status_code == 201, ok.text
|
||||
assert ok.json()["values"] == {"peso": "12,5"}
|
||||
assert ok.json()["attachments"] == [{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}]
|
||||
|
||||
# solo done
|
||||
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="ok")).status_code == 422
|
||||
# chiave fuori dai campi
|
||||
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
values={"altro": "1"})).status_code == 422
|
||||
# numero non convertibile
|
||||
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
values={"peso": "abc"})).status_code == 422
|
||||
# allegati con percorso non sicuro
|
||||
for bad in ("../x.jpg", "/etc/passwd", "a/../b.jpg", "C:/x.jpg", "a\\b.jpg"):
|
||||
resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
attachments=[{"key": None, "path": bad, "name": "x"}])
|
||||
assert resp.status_code == 422, bad
|
||||
# chiave allegato fuori dai campi
|
||||
resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
attachments=[{"key": "boh", "path": "task_results/a.jpg", "name": "x"}])
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_other_task_types_rejected_and_unknown_task(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, _, _ = await _setup(client, db_session, maker_user, admin_user, "TR3")
|
||||
measure = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
json={"title": "M", "task_type": "measure"},
|
||||
)).json()
|
||||
resp = await _post(client, measurement_tec_user, task_id=measure["id"], outcome="ok")
|
||||
assert resp.status_code == 422
|
||||
resp = await _post(client, measurement_tec_user, task_id=999999, outcome="ok")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_run_must_be_open_and_same_version(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4")
|
||||
_, other_check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4b")
|
||||
run = await _open_run(client, measurement_tec_user, recipe, "TR4")
|
||||
|
||||
# task di un'altra versione
|
||||
resp = await _post(client, measurement_tec_user, task_id=other_check["id"],
|
||||
production_run_id=run["id"], outcome="ok")
|
||||
assert resp.status_code == 422
|
||||
# run inesistente
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
production_run_id=999999, outcome="ok")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# run chiuso: non registra piu'
|
||||
row = (await db_session.execute(
|
||||
select(ProductionRun).where(ProductionRun.id == run["id"])
|
||||
)).scalar_one()
|
||||
row.status = "closed"
|
||||
await db_session.commit()
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
production_run_id=run["id"], outcome="ok")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_list_filters_and_order(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, check, note = await _setup(client, db_session, maker_user, admin_user, "TR5")
|
||||
run = await _open_run(client, measurement_tec_user, recipe, "TR5")
|
||||
a = (await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
production_run_id=run["id"], outcome="ok")).json()
|
||||
b = (await _post(client, measurement_tec_user, task_id=note["id"],
|
||||
production_run_id=run["id"], outcome="done")).json()
|
||||
c = (await _post(client, measurement_tec_user, task_id=check["id"], outcome="ok")).json()
|
||||
h = auth_headers(measurement_tec_user)
|
||||
|
||||
resp = await client.get(f"/api/task-results?production_run_id={run['id']}", headers=h)
|
||||
assert [r["id"] for r in resp.json()] == [b["id"], a["id"]] # piu' recente prima
|
||||
resp = await client.get(f"/api/task-results?task_id={check['id']}", headers=h)
|
||||
assert [r["id"] for r in resp.json()] == [c["id"], a["id"]]
|
||||
resp = await client.get(
|
||||
f"/api/task-results?task_id={check['id']}&production_run_id={run['id']}", headers=h)
|
||||
assert [r["id"] for r in resp.json()] == [a["id"]]
|
||||
assert (await client.get("/api/task-results")).status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/task-results/attachments: l'operatore carica gli allegati (#55)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# firma PNG valida (magic bytes) piu' un po' di zeri
|
||||
PNG = bytes([0x89]) + b"PNG" + bytes([13, 10, 26, 10]) + bytes(32)
|
||||
|
||||
|
||||
async def test_attachment_upload_ok_for_measurement_tec(
|
||||
client: AsyncClient, measurement_tec_user, tmp_path,
|
||||
):
|
||||
with patch("src.backend.api.routers.task_results.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
resp = await client.post(
|
||||
"/api/task-results/attachments", headers=auth_headers(measurement_tec_user),
|
||||
files={"file": ("foto bella (1).png", PNG, "image/png")},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["name"] == "fotobella1.png"
|
||||
assert body["file_path"].startswith("task_results/")
|
||||
assert body["file_path"].endswith(".png")
|
||||
assert (tmp_path / body["file_path"]).read_bytes() == PNG
|
||||
|
||||
|
||||
async def test_attachment_upload_rejects_wrong_type_size_and_role(
|
||||
client: AsyncClient, measurement_tec_user, maker_user, tmp_path,
|
||||
):
|
||||
h = auth_headers(measurement_tec_user)
|
||||
with patch("src.backend.api.routers.task_results.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 1
|
||||
# tipo non ammesso (HTML dichiarato come png): come upload_file, 400
|
||||
resp = await client.post(
|
||||
"/api/task-results/attachments", headers=h,
|
||||
files={"file": ("x.png", b"<html></html>", "image/png")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# oltre il limite
|
||||
resp = await client.post(
|
||||
"/api/task-results/attachments", headers=h,
|
||||
files={"file": ("x.png", PNG + bytes(1024 * 1024 + 1), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# un Maker senza ruolo di reparto non carica
|
||||
resp = await client.post(
|
||||
"/api/task-results/attachments", headers=auth_headers(maker_user),
|
||||
files={"file": ("x.png", PNG, "image/png")},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert not any(tmp_path.rglob("*.png"))
|
||||
|
||||
|
||||
async def test_task_results_dir_is_undeletable(client: AsyncClient, admin_user):
|
||||
resp = await client.delete(
|
||||
"/api/files/task_results/2026/10/abc.png", headers=auth_headers(admin_user),
|
||||
)
|
||||
assert resp.status_code in (403, 404)
|
||||
assert resp.status_code == 403
|
||||
@@ -66,7 +66,7 @@ async def two_vision_quotes(db_session, recipe_id: int):
|
||||
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:
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
return {
|
||||
"outputs": outputs,
|
||||
"failures": failures or [],
|
||||
@@ -316,7 +316,7 @@ async def test_a_pending_fail_blocks_before_the_worker_is_called(
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def worker_must_not_be_called(image_bytes: bytes, graph: dict, **kwargs) -> 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,
|
||||
|
||||
@@ -53,7 +53,7 @@ async def a_camera_task(db_session, recipe_id: int):
|
||||
@pytest.fixture
|
||||
def worker_says(monkeypatch):
|
||||
def _install(outputs: dict):
|
||||
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
return {"outputs": outputs, "failures": [],
|
||||
"engine_version": "e" * 40, "duration_ms": 3}
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Ricetta VisionSuite v2 su un task, execute dxf_compare, copy-on-write (#46)."""
|
||||
import io
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
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.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
RECIPE = {
|
||||
"schema_version": 2, "name": "dxf", "tools": [],
|
||||
"meta": {"dxf": {"d1": {"path": "dxf/part.dxf"}}, "models": {"m": {"path": "models/m.npz"}}},
|
||||
}
|
||||
OUTPUTS = [{"key": "cmp.points_in_pct", "quantity": "ratio", "unit": "%"}]
|
||||
GOOD_FILES = (("part.dxf", b"0 EOF"), ("m.npz", b"npz"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def redirect_uploads(monkeypatch, tmp_path):
|
||||
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
|
||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validate_says(monkeypatch):
|
||||
"""Il worker /validate non gira nei test: risposta finta."""
|
||||
def _install(ok=True, errors=None, outputs=None):
|
||||
async def fake(graph, recipe_dir):
|
||||
return {
|
||||
"ok": ok, "errors": errors or [],
|
||||
"outputs": outputs if outputs is not None else OUTPUTS,
|
||||
}
|
||||
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
|
||||
return _install
|
||||
|
||||
|
||||
def an_image() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("L", (32, 32)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
async def make_task(db_session, user, task_type="dxf_compare"):
|
||||
recipe = await create_test_recipe(db_session, user.id)
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().first()
|
||||
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()
|
||||
task.task_type = task_type
|
||||
await db_session.commit()
|
||||
return task, subtask
|
||||
|
||||
|
||||
def upload(client, user, task, recipe=RECIPE, files=()):
|
||||
return client.post(
|
||||
f"/api/vision/tasks/{task.id}/recipe",
|
||||
headers=auth_headers(user),
|
||||
files=[("recipe", ("r.json", json.dumps(recipe).encode(), "application/json"))]
|
||||
+ [("files", (n, c, "application/octet-stream")) for n, c in files],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_ok_stores_folder_and_task(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await upload(client, maker_user, task, files=GOOD_FILES)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["outputs"] == OUTPUTS
|
||||
assert sorted(body["files"]) == ["dxf/part.dxf", "models/m.npz"]
|
||||
assert body["recipe_dir"].startswith(f"vision/recipes/{task.id}/")
|
||||
folder = settings.upload_path / body["recipe_dir"]
|
||||
assert (folder / "recipe.json").is_file() and (folder / "dxf" / "part.dxf").is_file()
|
||||
await db_session.refresh(task)
|
||||
assert task.vision_json == RECIPE and task.vision_recipe_dir == body["recipe_dir"]
|
||||
|
||||
got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user))
|
||||
assert got.status_code == 200
|
||||
assert got.json()["recipe"] == RECIPE and got.json()["outputs"] == OUTPUTS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_without_recipe_is_empty(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user))
|
||||
assert got.json() == {
|
||||
"task_id": task.id, "recipe_dir": None, "recipe": None, "files": [], "outputs": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unmatched_file_name_is_422_list(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await upload(client, maker_user, task, files=[("other.dxf", b"x")])
|
||||
assert resp.status_code == 422
|
||||
assert isinstance(resp.json()["detail"], list) and "other.dxf" in resp.json()["detail"][0]
|
||||
await db_session.refresh(task)
|
||||
assert task.vision_json is None and task.vision_recipe_dir is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traversal_in_meta_path_is_422(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
evil = {"schema_version": 2, "tools": [], "meta": {"dxf": {"d": {"path": "../../evil.dxf"}}}}
|
||||
resp = await upload(client, maker_user, task, recipe=evil, files=[("evil.dxf", b"x")])
|
||||
assert resp.status_code == 422
|
||||
assert not (settings.upload_path.parent / "evil.dxf").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_task_type_is_422(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user, task_type="measure")
|
||||
resp = await upload(client, maker_user, task, files=GOOD_FILES)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_is_422(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await client.post(
|
||||
f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user),
|
||||
files=[("recipe", ("r.json", b"{nope", "application/json"))],
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_not_ok_is_422_and_task_unchanged(client, db_session, maker_user, validate_says):
|
||||
validate_says(ok=False, errors=["meta file missing"])
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
task.vision_json = {"old": 1}
|
||||
task.vision_recipe_dir = "vision/recipes/old"
|
||||
await db_session.commit()
|
||||
resp = await upload(client, maker_user, task, files=GOOD_FILES)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"] == ["meta file missing"]
|
||||
await db_session.refresh(task)
|
||||
assert task.vision_json == {"old": 1} and task.vision_recipe_dir == "vision/recipes/old"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_folder_is_not_rewritten(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
first = (await upload(client, maker_user, task, files=GOOD_FILES)).json()
|
||||
marker = settings.upload_path / first["recipe_dir"] / "dxf" / "part.dxf"
|
||||
marker.write_bytes(b"CHANGED")
|
||||
second = (await upload(client, maker_user, task, files=GOOD_FILES)).json()
|
||||
assert second["recipe_dir"] == first["recipe_dir"]
|
||||
assert marker.read_bytes() == b"CHANGED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_requires_maker(
|
||||
client, db_session, maker_user, measurement_tec_user, validate_says,
|
||||
):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await upload(client, measurement_tec_user, task, files=GOOD_FILES)
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_dxf_compare_saves_files_and_extended_response(
|
||||
client, db_session, measurement_tec_user, monkeypatch,
|
||||
):
|
||||
task, subtask = await make_task(db_session, measurement_tec_user)
|
||||
task.vision_json = RECIPE
|
||||
task.vision_recipe_dir = "vision/recipes/1/abc"
|
||||
subtask.vision_output = "cmp.points_in_pct"
|
||||
await db_session.commit()
|
||||
seen = {}
|
||||
overlay = [{"type": "contour", "role": "found", "rows": [1], "cols": [2], "tool_id": "cmp"}]
|
||||
|
||||
async def fake_call(image_bytes, graph, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return {
|
||||
"outputs": {"cmp.points_in_pct": float(subtask.nominal)},
|
||||
"failures": [], "engine_version": "e" * 40, "duration_ms": 5,
|
||||
"overlay": overlay, "outcome": "OK",
|
||||
}
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
|
||||
resp = 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 resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert seen["recipe_dir"] == "vision/recipes/1/abc" and "calibration" not in seen
|
||||
assert body["outcome"] == "OK" and body["overlay"] == overlay and body["failures"] == []
|
||||
assert body["image_url"] == f"/api/vision/results/{body['vision_result_id']}/image"
|
||||
assert body["measurements"][0]["pass_fail"] == "pass"
|
||||
|
||||
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
assert (settings.upload_path / result.image_path).read_bytes() == an_image()
|
||||
assert json.loads((settings.upload_path / result.overlay_path).read_text()) == overlay
|
||||
|
||||
img = await client.get(body["image_url"], headers=auth_headers(measurement_tec_user))
|
||||
assert img.status_code == 200 and img.content == an_image()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_dxf_compare_out_of_tolerance_fails(
|
||||
client, db_session, measurement_tec_user, monkeypatch,
|
||||
):
|
||||
task, subtask = await make_task(db_session, measurement_tec_user)
|
||||
task.vision_json = RECIPE
|
||||
subtask.vision_output = "cmp.points_in_pct"
|
||||
await db_session.commit()
|
||||
|
||||
async def fake_call(image_bytes, graph, **kwargs):
|
||||
return {"outputs": {"cmp.points_in_pct": 99.0}, "failures": [],
|
||||
"engine_version": "e" * 40, "outcome": "OK"}
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
resp = 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 resp.status_code == 200
|
||||
# il verdetto e' delle tolleranze del subtask, non di outcome di VisionSuite
|
||||
assert resp.json()["measurements"][0]["pass_fail"] == "fail"
|
||||
assert resp.json()["overlay"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_on_write_carries_recipe_dir(client, db_session, maker_user):
|
||||
from src.backend.tests.test_vision_copy_on_write import (
|
||||
_current_task_and_subtask, _seed_measurement,
|
||||
)
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||
task.task_type = "dxf_compare"
|
||||
task.vision_json = RECIPE
|
||||
task.vision_recipe_dir = "vision/recipes/1/abc"
|
||||
await db_session.flush()
|
||||
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": "cow"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
db_session.expunge_all()
|
||||
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||
assert version.version_number == 2
|
||||
assert new_task.vision_recipe_dir == "vision/recipes/1/abc" and new_task.vision_json == RECIPE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_with_missing_file_then_complete_succeeds(
|
||||
client, db_session, maker_user, monkeypatch,
|
||||
):
|
||||
# worker finto: ok solo se il DXF esiste nella cartella della ricetta
|
||||
async def fake(graph, recipe_dir):
|
||||
present = (settings.upload_path / recipe_dir / "dxf" / "part.dxf").is_file()
|
||||
return {"ok": present, "errors": [] if present else ["dxf missing"], "outputs": OUTPUTS}
|
||||
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
|
||||
first = await upload(client, maker_user, task, files=[])
|
||||
assert first.status_code == 422
|
||||
second = await upload(client, maker_user, task, files=GOOD_FILES)
|
||||
assert second.status_code == 200, second.text
|
||||
@@ -39,7 +39,7 @@ async def recipe_parts(db_session, recipe_id: int):
|
||||
@pytest.fixture
|
||||
def worker_says(monkeypatch):
|
||||
def _install(outputs: dict):
|
||||
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
return {"outputs": outputs, "failures": [],
|
||||
"engine_version": "b" * 40, "duration_ms": 7}
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""#45: il worker di visione si sceglie per stazione.
|
||||
|
||||
La stazione si legge dalla sessione di produzione (`production_run_id`): con un
|
||||
URL sulla stazione `_call_worker` lo riceve e il VisionResult lo registra;
|
||||
senza URL o senza run si resta sul worker centrale, senza ripiego fra i due.
|
||||
"""
|
||||
import io
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from PIL import Image
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.api.production import ProductionRunCreate
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
from src.backend.services import production_service
|
||||
from src.backend.services.vision_service import _call_worker
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.tests.test_vision_execute import recipe_parts
|
||||
from sqlalchemy import select
|
||||
|
||||
STATION_URL = "http://10.0.0.7:8100"
|
||||
|
||||
|
||||
def an_image() -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_calls(monkeypatch):
|
||||
"""Fake `_call_worker` che ricorda i kwargs con cui e' stato chiamato."""
|
||||
calls: list[dict] = []
|
||||
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
calls.append(kwargs)
|
||||
return {"outputs": {"t1.diameter": 10.0}, "failures": [],
|
||||
"engine_version": "e" * 40, "duration_ms": 3}
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
return calls
|
||||
|
||||
|
||||
async def _camera_task(db_session, user_id: int):
|
||||
recipe = await create_test_recipe(db_session, 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()
|
||||
return recipe, task
|
||||
|
||||
|
||||
async def _station_with_run(db_session, user, recipe, worker_url):
|
||||
station = Station(
|
||||
code="ST-W", name="Stazione W", active=True, created_by=user.id,
|
||||
vision_worker_url=worker_url,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(station)
|
||||
run = await production_service.open_run(
|
||||
db_session,
|
||||
ProductionRunCreate(station_code=station.code, recipe_id=recipe.id),
|
||||
user,
|
||||
)
|
||||
await db_session.commit()
|
||||
return station, run
|
||||
|
||||
|
||||
async def _execute(client, user, task, run=None):
|
||||
data = {"task_id": str(task.id)}
|
||||
if run is not None:
|
||||
data["production_run_id"] = str(run.id)
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data=data,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
async def _the_result(db_session) -> VisionResult:
|
||||
return (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_station_with_url_gets_the_call_and_the_record(
|
||||
client, db_session, measurement_tec_user, worker_calls,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
station, run = await _station_with_run(
|
||||
db_session, measurement_tec_user, recipe, STATION_URL)
|
||||
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
|
||||
assert worker_calls[0]["worker_url"] == STATION_URL
|
||||
result = await _the_result(db_session)
|
||||
assert result.executed_on == "station"
|
||||
assert result.worker_url == STATION_URL
|
||||
assert result.station_id == station.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_station_without_url_uses_the_central_worker(
|
||||
client, db_session, measurement_tec_user, worker_calls,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
station, run = await _station_with_run(
|
||||
db_session, measurement_tec_user, recipe, None)
|
||||
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
|
||||
assert worker_calls[0]["worker_url"] is None
|
||||
result = await _the_result(db_session)
|
||||
assert result.executed_on == "server"
|
||||
assert result.worker_url == settings.vision_worker_url.rstrip("/")
|
||||
assert result.station_id == station.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_without_a_run_the_central_worker_is_used(
|
||||
client, db_session, measurement_tec_user, worker_calls,
|
||||
):
|
||||
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
|
||||
await _execute(client, measurement_tec_user, task)
|
||||
|
||||
assert worker_calls[0]["worker_url"] is None
|
||||
result = await _the_result(db_session)
|
||||
assert result.executed_on == "server"
|
||||
assert result.worker_url == settings.vision_worker_url.rstrip("/")
|
||||
assert result.station_id is None
|
||||
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
return {"outputs": {}, "failures": [], "engine_version": "e" * 40}
|
||||
|
||||
|
||||
class _RecordingClient:
|
||||
"""httpx.AsyncClient finto: registra gli URL postati, opzionalmente fallisce."""
|
||||
|
||||
def __init__(self, posts: list, raises=None):
|
||||
self._posts = posts
|
||||
self._raises = raises
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
async def post(self, url, *args, **kwargs):
|
||||
self._posts.append(url)
|
||||
if self._raises is not None:
|
||||
raise self._raises
|
||||
return _Response()
|
||||
|
||||
|
||||
def _install(monkeypatch, raises=None) -> list:
|
||||
posts: list = []
|
||||
monkeypatch.setattr(
|
||||
"src.backend.services.vision_service.httpx.AsyncClient",
|
||||
lambda *a, **k: _RecordingClient(posts, raises),
|
||||
)
|
||||
return posts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_worker_posts_to_the_given_url(monkeypatch):
|
||||
posts = _install(monkeypatch)
|
||||
# lo "/" finale non deve produrre "//run"
|
||||
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL + "/")
|
||||
assert posts == [f"{STATION_URL}/run"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_worker_defaults_to_the_central_url(monkeypatch):
|
||||
posts = _install(monkeypatch)
|
||||
await _call_worker(b"img", {"tools": []})
|
||||
assert posts == [f"{settings.vision_worker_url.rstrip('/')}/run"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_station_worker_down_is_502_with_its_url_and_no_fallback(monkeypatch):
|
||||
posts = _install(monkeypatch, raises=httpx.ConnectError("refused"))
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL)
|
||||
assert exc.value.status_code == 502
|
||||
assert STATION_URL in exc.value.detail
|
||||
# una sola POST: il centrale non viene tentato
|
||||
assert posts == [f"{STATION_URL}/run"]
|
||||
@@ -69,7 +69,7 @@ async def recipe_parts(db_session, recipe_id: int):
|
||||
@pytest.fixture
|
||||
def worker_says(monkeypatch):
|
||||
def _install(outputs: dict):
|
||||
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
return {"outputs": outputs, "failures": [],
|
||||
"engine_version": "c" * 40, "duration_ms": 5}
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -74,12 +74,14 @@ def create_app() -> Flask:
|
||||
from blueprints.maker import maker_bp
|
||||
from blueprints.statistics import statistics_bp
|
||||
from blueprints.admin import admin_bp
|
||||
from blueprints.monitor import monitor_bp # #57 live station monitor
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(measure_bp, url_prefix="/measure")
|
||||
app.register_blueprint(maker_bp, url_prefix="/maker")
|
||||
app.register_blueprint(statistics_bp, url_prefix="/statistics")
|
||||
app.register_blueprint(admin_bp, url_prefix="/admin")
|
||||
app.register_blueprint(monitor_bp, url_prefix="/monitor") # #57
|
||||
|
||||
@app.before_request
|
||||
def _check_secret_key():
|
||||
@@ -240,6 +242,9 @@ def create_app() -> Flask:
|
||||
"company_logo": session.get("company_logo"),
|
||||
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
||||
"current_year": date.today().year,
|
||||
# Versione/commit nel piede di pagina (#70)
|
||||
"app_version": Config.APP_VERSION,
|
||||
"app_commit": Config.APP_COMMIT,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
@@ -29,28 +29,30 @@ def admin_required(f):
|
||||
@login_required
|
||||
@admin_required
|
||||
def user_list():
|
||||
"""User management page."""
|
||||
resp = api_client.get("/api/users")
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
flash(_("Errore nel caricamento degli utenti: %(error)s", error=resp.get("detail", "")), "error")
|
||||
users = []
|
||||
elif isinstance(resp, list):
|
||||
users = resp
|
||||
else:
|
||||
users = []
|
||||
|
||||
return render_template("admin/users.html", users=users)
|
||||
"""#50: la gestione utenti vive nella tab «Utenti» di Impostazioni."""
|
||||
return redirect(url_for("admin.settings_page", tab="utenti"))
|
||||
|
||||
|
||||
@admin_bp.route("/settings")
|
||||
@login_required
|
||||
@admin_required
|
||||
def settings_page():
|
||||
"""System settings page."""
|
||||
"""System settings page (admin only), con la tab «Utenti» (#50)."""
|
||||
resp = api_client.get("/api/settings")
|
||||
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
|
||||
return render_template("admin/settings.html", settings=settings)
|
||||
# Solo un dict valido sono impostazioni: un errore o altro tipo = nessuna
|
||||
settings = resp if isinstance(resp, dict) and not resp.get("error") else {}
|
||||
|
||||
users_resp = api_client.get("/api/users")
|
||||
if isinstance(users_resp, dict) and users_resp.get("error"):
|
||||
flash(_("Errore nel caricamento degli utenti: %(error)s", error=users_resp.get("detail", "")), "error")
|
||||
users = []
|
||||
elif isinstance(users_resp, list):
|
||||
users = users_resp
|
||||
else:
|
||||
users = []
|
||||
|
||||
tab = "utenti" if request.args.get("tab") == "utenti" else "generale"
|
||||
return render_template("admin/settings.html", settings=settings, users=users, active_tab=tab)
|
||||
|
||||
|
||||
@admin_bp.route("/api/settings", methods=["PUT"])
|
||||
@@ -136,6 +138,21 @@ def api_change_password(user_id: int):
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@admin_bp.route("/api/users/<int:user_id>/pin", methods=["PUT"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_set_pin(user_id: int):
|
||||
"""Proxy: set (4 digits) or remove ({"pin": null}) a user's PIN (#59)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
# Forward only the pin field; backend answers 422 for admins / malformed PINs.
|
||||
resp = api_client.put(f"/api/users/{user_id}/pin", data={"pin": data.get("pin")})
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
|
||||
@@ -59,6 +59,45 @@ def _post_login_landing():
|
||||
return url_for("auth.profile")
|
||||
|
||||
|
||||
def _complete_login(response, fallback_name):
|
||||
"""Dopo una risposta di login riuscita: sessione, preferenze, impostazioni (#59).
|
||||
|
||||
Comune al login con password e al login con PIN, cosi' i due restano identici.
|
||||
Ritorna l'utente salvato in sessione.
|
||||
"""
|
||||
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
|
||||
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
|
||||
# limite configurato non ha mai effetto (#33).
|
||||
session.permanent = True
|
||||
session["api_key"] = response["api_key"]
|
||||
session["user"] = response["user"]
|
||||
session["user_id"] = response["user"]["id"]
|
||||
|
||||
# Applica preferenze utente
|
||||
user = response["user"]
|
||||
if user.get("language_pref"):
|
||||
session["language"] = user["language_pref"]
|
||||
if user.get("theme_pref"):
|
||||
session["theme"] = user["theme_pref"]
|
||||
|
||||
# Carica impostazioni di sistema
|
||||
settings_resp = api_client.get("/api/settings")
|
||||
if not settings_resp.get("error"):
|
||||
logo_path = settings_resp.get("company_logo_path")
|
||||
if logo_path:
|
||||
session["company_logo"] = logo_path
|
||||
auto_logout = settings_resp.get("auto_logout_minutes")
|
||||
if auto_logout:
|
||||
try:
|
||||
session["auto_logout_minutes"] = int(auto_logout)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", fallback_name)), "success")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Login page and form handler."""
|
||||
@@ -81,35 +120,7 @@ def login():
|
||||
flash(response.get("detail", _("Credenziali non valide")), "error")
|
||||
return render_template("auth/login.html")
|
||||
|
||||
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
|
||||
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
|
||||
# limite configurato non ha mai effetto (#33).
|
||||
session.permanent = True
|
||||
session["api_key"] = response["api_key"]
|
||||
session["user"] = response["user"]
|
||||
session["user_id"] = response["user"]["id"]
|
||||
|
||||
# Applica preferenze utente
|
||||
user = response["user"]
|
||||
if user.get("language_pref"):
|
||||
session["language"] = user["language_pref"]
|
||||
if user.get("theme_pref"):
|
||||
session["theme"] = user["theme_pref"]
|
||||
|
||||
# Carica impostazioni di sistema
|
||||
settings_resp = api_client.get("/api/settings")
|
||||
if not settings_resp.get("error"):
|
||||
logo_path = settings_resp.get("company_logo_path")
|
||||
if logo_path:
|
||||
session["company_logo"] = logo_path
|
||||
auto_logout = settings_resp.get("auto_logout_minutes")
|
||||
if auto_logout:
|
||||
try:
|
||||
session["auto_logout_minutes"] = int(auto_logout)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
||||
user = _complete_login(response, username)
|
||||
|
||||
# Redirect dopo login (in base al ruolo)
|
||||
return redirect(_post_login_landing())
|
||||
@@ -121,6 +132,55 @@ def login():
|
||||
return render_template("auth/login.html")
|
||||
|
||||
|
||||
@auth_bp.route("/pin-users", methods=["GET"])
|
||||
def pin_users():
|
||||
"""Elenco nomi per la vista Operatore del login (#59): solo id e nome.
|
||||
|
||||
Pubblica come il login; se il backend non risponde o non ha il PIN, lista
|
||||
vuota e la pagina apre il form password.
|
||||
"""
|
||||
if session.get("api_key"):
|
||||
return jsonify([])
|
||||
try:
|
||||
resp = api_client.get("/api/auth/pin-users")
|
||||
except Exception:
|
||||
return jsonify([])
|
||||
if not isinstance(resp, list):
|
||||
return jsonify([])
|
||||
return jsonify([
|
||||
{"id": u.get("id"), "display_name": u.get("display_name", "")}
|
||||
for u in resp if isinstance(u, dict)
|
||||
])
|
||||
|
||||
|
||||
@auth_bp.route("/login-pin", methods=["POST"])
|
||||
def login_pin():
|
||||
"""Login con nome + PIN (#59). JSON in/out; CSRF come il login password
|
||||
(header X-CSRFToken, gestito da CSRFProtect)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
user_id = data.get("user_id")
|
||||
pin = str(data.get("pin", ""))
|
||||
if not isinstance(user_id, int) or not (len(pin) == 4 and pin.isdigit()):
|
||||
return jsonify({"error": True, "status_code": 422, "detail": _("Il PIN deve essere di 4 cifre")}), 422
|
||||
|
||||
try:
|
||||
response = api_client.post("/api/auth/login-pin", data={"user_id": user_id, "pin": pin})
|
||||
except Exception as e:
|
||||
return jsonify({"error": True, "status_code": 502,
|
||||
"detail": _("Errore di connessione al server: %(error)s", error=str(e))}), 502
|
||||
|
||||
if response.get("error"):
|
||||
status = response.get("status_code") or 401
|
||||
if status == 401:
|
||||
detail = _("PIN errato")
|
||||
else:
|
||||
detail = response.get("detail") or _("Credenziali non valide")
|
||||
return jsonify({"error": True, "status_code": status, "detail": detail}), status
|
||||
|
||||
_complete_login(response, "") # include il flash di benvenuto
|
||||
return jsonify({"ok": True, "redirect": _post_login_landing()})
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
"""Clear session and redirect to login."""
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Maker blueprint - recipe creation and editing."""
|
||||
import json
|
||||
import math
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
@@ -57,7 +60,20 @@ def recipe_list():
|
||||
@role_required("Maker")
|
||||
def recipe_new():
|
||||
"""Create new recipe."""
|
||||
return render_template("maker/recipe_editor.html", recipe=None)
|
||||
# #49: loop duration pre-filled from the system default (new recipes only);
|
||||
# missing/empty/invalid key -> None -> behaviour unchanged
|
||||
default_interval = None
|
||||
settings = api_client.get("/api/settings")
|
||||
if isinstance(settings, dict) and not settings.get("error"):
|
||||
try:
|
||||
value = int(str(settings.get("default_measurement_interval_minutes", "")).strip())
|
||||
if 1 <= value <= 1440:
|
||||
default_interval = value
|
||||
except ValueError:
|
||||
pass
|
||||
return render_template(
|
||||
"maker/recipe_editor.html", recipe=None, default_interval=default_interval
|
||||
)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/<int:recipe_id>/edit")
|
||||
@@ -105,7 +121,41 @@ def task_editor(recipe_id: int):
|
||||
else:
|
||||
recipe["tasks"] = []
|
||||
|
||||
return render_template("maker/task_editor.html", recipe=recipe)
|
||||
# #47/#48: liste e tolleranze generali dalle Impostazioni; chiave assente,
|
||||
# vuota o non valida -> None -> il template usa le voci scritte a mano
|
||||
settings = api_client.get("/api/settings")
|
||||
if not isinstance(settings, dict) or settings.get("error"):
|
||||
settings = {}
|
||||
|
||||
return render_template(
|
||||
"maker/task_editor.html",
|
||||
recipe=recipe,
|
||||
list_measurement_types=_settings_string_list(settings.get("list_measurement_types")),
|
||||
list_units=_settings_string_list(settings.get("list_units")),
|
||||
general_tolerance_plus=_settings_tolerance(settings.get("general_tolerance_plus")),
|
||||
general_tolerance_minus=_settings_tolerance(settings.get("general_tolerance_minus")),
|
||||
)
|
||||
|
||||
|
||||
def _settings_string_list(value):
|
||||
"""#47: array JSON di stringhe non vuote dalle Impostazioni, altrimenti None."""
|
||||
try:
|
||||
items = json.loads(value) if isinstance(value, str) else None
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(items, list):
|
||||
return None
|
||||
items = [i.strip() for i in items if isinstance(i, str) and i.strip()]
|
||||
return items or None
|
||||
|
||||
|
||||
def _settings_tolerance(value):
|
||||
"""#48: tolleranza generale (decimale >= 0) dalle Impostazioni, altrimenti None."""
|
||||
try:
|
||||
number = float(str(value).strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return number if math.isfinite(number) and number >= 0 else None
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/<int:recipe_id>/tasks/<int:task_id>/drawing")
|
||||
@@ -201,6 +251,21 @@ def api_create_recipe():
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@maker_bp.route("/api/recipes/<int:recipe_id>/duplicate", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_duplicate_recipe(recipe_id: int):
|
||||
"""Proxy: Duplicate a recipe under a new code/name (#52)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.post(f"/api/recipes/{recipe_id}/duplicate", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||
import urllib.parse
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from flask import (
|
||||
Blueprint, abort, flash, jsonify, redirect, render_template,
|
||||
request, session, url_for,
|
||||
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
||||
g, request, session, url_for,
|
||||
)
|
||||
from flask_babel import gettext as _
|
||||
|
||||
@@ -343,6 +345,7 @@ def task_execute(task_id: int):
|
||||
all_task_ids=all_task_ids,
|
||||
measurement_interval_minutes=measurement_interval_minutes,
|
||||
allow_manual_input=allow_manual_input,
|
||||
camera_enabled=_station_camera(_current_station()[0]) is not None,
|
||||
)
|
||||
|
||||
|
||||
@@ -652,6 +655,74 @@ def api_pending_authorisation():
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
def _supervisor_credentials(data: dict):
|
||||
"""The capoturno's credentials from the request body (#59).
|
||||
|
||||
Two ways in: ``supervisor_user_id`` + ``supervisor_pin`` (name and 4-digit PIN
|
||||
from the touch pad) or, as before, ``supervisor_username`` +
|
||||
``supervisor_password``. Returns ``(fields, None)`` with the pair to forward,
|
||||
or ``(None, error_response)`` when neither pair is complete.
|
||||
"""
|
||||
pin = str(data.get("supervisor_pin") or "").strip()
|
||||
user_id = data.get("supervisor_user_id")
|
||||
if pin and user_id not in (None, ""):
|
||||
try:
|
||||
return {"supervisor_user_id": int(user_id), "supervisor_pin": pin}, None
|
||||
except (TypeError, ValueError):
|
||||
pass # user_id not a number: falls through to the missing-credentials answer
|
||||
username = (data.get("supervisor_username") or "").strip()
|
||||
password = data.get("supervisor_password") or ""
|
||||
if username and password:
|
||||
return {"supervisor_username": username, "supervisor_password": password}, None
|
||||
return None, (jsonify({
|
||||
"error": True, "detail": _("Username e password richiesti"),
|
||||
}), 400)
|
||||
|
||||
|
||||
def _post_supervised(endpoint: str, creds: dict, extra: dict | None = None):
|
||||
"""POST an action carrying the capoturno's credentials, forwarding the answer.
|
||||
|
||||
api_client reads every 401 as "your API key is dead" and empties the Flask
|
||||
session. For these endpoints a 401 can also be the capoturno's wrong PIN or
|
||||
password, and that must not throw the operator out of their session (#59):
|
||||
when the detail names the supervisor or the PIN, the session is put back and
|
||||
the ``session_expired`` flag is dropped so the modal can say "PIN errato".
|
||||
"""
|
||||
saved = dict(session)
|
||||
resp = api_client.post(endpoint, data={**creds, **(extra or {})})
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
detail = str(resp.get("detail") or "").lower()
|
||||
if resp.get("status_code") == 401 and ("supervisor" in detail or "pin" in detail):
|
||||
session.update(saved) # the 401 was about the capoturno, not about this session
|
||||
g.session_expired = False
|
||||
resp = {k: v for k, v in resp.items() if k != "session_expired"}
|
||||
if "supervisor_pin" in creds:
|
||||
resp["detail"] = _("PIN errato") # the backend's English text is not for the operator
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@measure_bp.route("/api/pin-users", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_pin_users():
|
||||
"""Proxy: names for the capoturno's PIN pad (#59), id and display name only.
|
||||
|
||||
Empty list when the backend does not answer, so the modal falls back to the
|
||||
password form instead of showing a pad nobody can use.
|
||||
"""
|
||||
try:
|
||||
resp = api_client.get("/api/auth/pin-users")
|
||||
except Exception:
|
||||
return jsonify([])
|
||||
if not isinstance(resp, list):
|
||||
return jsonify([])
|
||||
return jsonify([
|
||||
{"id": u.get("id"), "display_name": u.get("display_name", "")}
|
||||
for u in resp if isinstance(u, dict)
|
||||
])
|
||||
|
||||
|
||||
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
@@ -663,20 +734,10 @@ def api_authorise_measurement(measurement_id: int):
|
||||
written down nowhere and nothing depending on it.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
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
|
||||
|
||||
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
|
||||
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
||||
if error:
|
||||
return error
|
||||
return _post_supervised(f"/api/measurements/{measurement_id}/authorise", creds)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -768,24 +829,12 @@ def _supervised_action(run_id: int, action: str):
|
||||
leave no such record, and is an extra round trip besides.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
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
|
||||
|
||||
resp = api_client.post(
|
||||
f"/api/production-runs/{run_id}/{action}",
|
||||
data={
|
||||
"supervisor_username": username,
|
||||
"supervisor_password": password,
|
||||
"note": data.get("note"),
|
||||
},
|
||||
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
||||
if error:
|
||||
return error
|
||||
return _post_supervised(
|
||||
f"/api/production-runs/{run_id}/{action}", creds, {"note": data.get("note")},
|
||||
)
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@measure_bp.route("/api/production/<int:run_id>/pause", methods=["POST"])
|
||||
@@ -812,6 +861,91 @@ def api_close_production(run_id: int):
|
||||
return _supervised_action(run_id, "close")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes: task results (#53 #54 #55) - outcome of a "check" task and the values /
|
||||
# attachments of a "note" task with fields. The browser cannot send X-API-Key,
|
||||
# so each call goes through here.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _relay(resp, ok_status: int = 200):
|
||||
"""JSON back to the browser: backend errors keep their status and detail.
|
||||
|
||||
The detail is what the operator reads («Per un NOK serve la nota»), so it is
|
||||
passed through as it came, never replaced by a generic message.
|
||||
"""
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
body = {"error": True, "detail": resp.get("detail", _("Errore nel salvataggio"))}
|
||||
if resp.get("session_expired"):
|
||||
body["session_expired"] = True
|
||||
status_code = resp.get("status_code", 500)
|
||||
return jsonify(body), status_code if status_code >= 400 else 500
|
||||
return jsonify(resp), ok_status
|
||||
|
||||
|
||||
@measure_bp.route("/api/tasks/<int:task_id>", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_get_task(task_id: int):
|
||||
"""Proxy: a task with its fields (#55: the tasks to fill at the end of production)."""
|
||||
return _relay(api_client.get(f"/api/tasks/{task_id}"))
|
||||
|
||||
|
||||
@measure_bp.route("/api/task-results", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_save_task_result():
|
||||
"""Proxy: record the result of a check / note task (#54, #55)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
if data.get("task_id") is None:
|
||||
return jsonify({
|
||||
"error": True, "detail": _("Dati mancanti: task_id è obbligatorio"),
|
||||
}), 400
|
||||
payload = {
|
||||
"task_id": data.get("task_id"),
|
||||
"production_run_id": data.get("production_run_id"),
|
||||
"outcome": data.get("outcome"),
|
||||
"note": data.get("note"),
|
||||
"values": data.get("values"),
|
||||
"attachments": data.get("attachments"),
|
||||
}
|
||||
return _relay(api_client.post("/api/task-results", data=payload), 201)
|
||||
|
||||
|
||||
@measure_bp.route("/api/task-results", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_list_task_results():
|
||||
"""Proxy: results already recorded, filtered by production run and/or task."""
|
||||
params = {
|
||||
key: request.args[key]
|
||||
for key in ("production_run_id", "task_id")
|
||||
if request.args.get(key)
|
||||
}
|
||||
return _relay(api_client.get("/api/task-results", params=params))
|
||||
|
||||
|
||||
@measure_bp.route("/api/attachments", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_upload_attachment():
|
||||
"""Proxy: upload an attachment of a note task (image or PDF), multipart.
|
||||
|
||||
Answers with the backend's reply: the `file_path` in it is what goes into the
|
||||
`attachments` of the result.
|
||||
"""
|
||||
upload = request.files.get("file")
|
||||
if upload is None or not upload.filename:
|
||||
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
|
||||
resp = api_client.post(
|
||||
"/api/task-results/attachments", # #55: upload per l operatore (files/upload e solo Maker)
|
||||
files={"file": (
|
||||
upload.filename,
|
||||
upload.read(),
|
||||
upload.mimetype or "application/octet-stream",
|
||||
)},
|
||||
)
|
||||
return _relay(resp, 201)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: File proxy (browser can't send X-API-Key directly)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -820,3 +954,125 @@ def api_close_production(run_id: int):
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
return proxy_file(file_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: vision execute (#46) - the browser sends an image, we relay it to
|
||||
# POST /api/vision/execute (multipart) because it cannot send X-API-Key itself.
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/api/vision/execute", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_execute():
|
||||
"""Proxy: run the task's VisionSuite recipe on the uploaded image."""
|
||||
image = request.files.get("image")
|
||||
task_id = request.form.get("task_id")
|
||||
if image is None or not task_id:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
|
||||
}), 400
|
||||
|
||||
# Same context fields as the manual flow; empty ones are left out so the
|
||||
# backend sees them as absent rather than as "".
|
||||
form = {"task_id": task_id}
|
||||
for key in ("task_type", "lot_number", "serial_number", "production_run_id"):
|
||||
val = request.form.get(key)
|
||||
if val:
|
||||
form[key] = val
|
||||
form.setdefault("lot_number", session.get("lot_number", ""))
|
||||
form.setdefault("serial_number", session.get("serial_number", ""))
|
||||
|
||||
resp = api_client.post(
|
||||
"/api/vision/execute",
|
||||
data=form,
|
||||
files={"image": (
|
||||
image.filename or "capture.jpg",
|
||||
image.read(),
|
||||
image.mimetype or "application/octet-stream",
|
||||
)},
|
||||
)
|
||||
if resp.get("error"):
|
||||
status_code = resp.get("status_code", 500)
|
||||
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
|
||||
if resp.get("session_expired"):
|
||||
body["session_expired"] = True
|
||||
return jsonify(body), status_code if status_code >= 400 else 500
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||
"""(camera_url, camera_source) of the station, None when it has no camera.
|
||||
|
||||
A station that cannot be read counts as "no camera": the grab button simply
|
||||
does not show, the file input keeps working.
|
||||
"""
|
||||
if not station_code:
|
||||
return None
|
||||
station = api_client.get_station_by_code(station_code)
|
||||
if not isinstance(station, dict) or station.get("error"):
|
||||
return None
|
||||
url = station.get("camera_url")
|
||||
source = station.get("camera_source")
|
||||
if not url or not source:
|
||||
return None
|
||||
return str(url), str(source)
|
||||
|
||||
|
||||
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
|
||||
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
|
||||
@measure_bp.route("/api/vision/grab", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_grab():
|
||||
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
|
||||
camera = _station_camera(_current_station()[0])
|
||||
if camera is None:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Camera non configurata per questa stazione"),
|
||||
}), 409
|
||||
camera_url, camera_source = camera
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{camera_url.rstrip('/')}/camera/grab",
|
||||
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
||||
timeout=15,
|
||||
)
|
||||
except http_requests.RequestException as exc:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
||||
}), 502
|
||||
if resp.status_code != 200:
|
||||
detail = ""
|
||||
try:
|
||||
body = resp.json()
|
||||
detail = body.get("detail", "") if isinstance(body, dict) else ""
|
||||
except ValueError:
|
||||
detail = (resp.text or "")[:200]
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Acquisizione non riuscita (%(status)s): %(detail)s",
|
||||
status=resp.status_code, detail=detail),
|
||||
}), 502
|
||||
return Response(resp.content, content_type="image/png")
|
||||
|
||||
|
||||
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_result_image(result_id: int):
|
||||
"""Proxy: the image saved with a vision result (needs X-API-Key)."""
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/vision/results/{result_id}/image",
|
||||
headers={"X-API-Key": session.get("api_key", "")},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return Response(resp.text, status=resp.status_code)
|
||||
return Response(
|
||||
resp.content,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Live monitor of the open production runs (#57).
|
||||
|
||||
Page at /monitor for admin and Supervisor (capoturno); the page polls a JSON proxy
|
||||
of GET /api/production-runs/open every 10 seconds.
|
||||
"""
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, abort, jsonify, render_template, session
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from services.api_client import api_client
|
||||
|
||||
monitor_bp = Blueprint("monitor", __name__)
|
||||
|
||||
|
||||
def monitor_required(f):
|
||||
"""Admin or Supervisor only: the same people the backend lets read /open."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
user = session.get("user", {})
|
||||
if not (user.get("is_admin") or "Supervisor" in user.get("roles", [])):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
@monitor_bp.route("", strict_slashes=False)
|
||||
@login_required
|
||||
@monitor_required
|
||||
def index():
|
||||
return render_template("monitor/index.html")
|
||||
|
||||
|
||||
@monitor_bp.route("/api/runs")
|
||||
@login_required
|
||||
@monitor_required
|
||||
def api_runs():
|
||||
"""JSON proxy polled by the page."""
|
||||
resp = api_client.get("/api/production-runs/open")
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
body = {"error": True, "detail": resp.get("detail", "")}
|
||||
if resp.get("session_expired"):
|
||||
body["session_expired"] = True
|
||||
return jsonify(body), resp.get("status_code", 500)
|
||||
return jsonify(resp)
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request, session
|
||||
from flask import Blueprint, abort, jsonify, render_template, request, session
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
from services.file_proxy import proxy_file # #58: allegati dei task
|
||||
|
||||
statistics_bp = Blueprint("statistics", __name__)
|
||||
|
||||
@@ -50,7 +51,7 @@ def _proxy_statistics(endpoint: str):
|
||||
for key in [
|
||||
"recipe_id", "version_id", "subtask_id",
|
||||
"date_from", "date_to", "operator_id",
|
||||
"lot_number", "serial_number", "n_bins",
|
||||
"lot_number", "serial_number", "n_bins", "production_run_id", # #58
|
||||
]:
|
||||
val = request.args.get(key)
|
||||
if val is not None and val != "":
|
||||
@@ -127,6 +128,54 @@ def api_subtasks():
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/runs")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_runs():
|
||||
"""Proxy: production runs of a recipe (#58, "Sessione" filter)."""
|
||||
params = {}
|
||||
recipe_id = request.args.get("recipe_id")
|
||||
if recipe_id:
|
||||
params["recipe_id"] = recipe_id
|
||||
|
||||
resp = api_client.get("/api/statistics/runs", params=params)
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
error_body = {"error": True, "detail": resp.get("detail", "")}
|
||||
if resp.get("session_expired"):
|
||||
error_body["session_expired"] = True
|
||||
return jsonify(error_body), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/runs/<int:run_id>/task-results")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_run_task_results(run_id: int):
|
||||
"""Proxy: esiti, note, campi e allegati dei task di una sessione (#58)."""
|
||||
resp = api_client.get(f"/api/statistics/runs/{run_id}/task-results")
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
error_body = {"error": True, "detail": resp.get("detail", "")}
|
||||
if resp.get("session_expired"):
|
||||
error_body["session_expired"] = True
|
||||
return jsonify(error_body), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/files/<path:file_path>")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_task_result_file(file_path: str):
|
||||
"""Proxy: allegati dei task (#58). Solo task_results/, mai altri file."""
|
||||
# #58: il Metrologist non deve poter leggere altro dell'upload dir (es. statistics/).
|
||||
if not file_path.startswith("task_results/") or ".." in file_path.split("/"):
|
||||
abort(404)
|
||||
return proxy_file(file_path)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PROXY REPORT PDF → FastAPI
|
||||
# ============================================================================
|
||||
@@ -137,7 +186,7 @@ def _proxy_report(endpoint: str):
|
||||
for key in [
|
||||
"recipe_id", "version_id", "subtask_id",
|
||||
"date_from", "date_to", "operator_id",
|
||||
"lot_number", "serial_number",
|
||||
"lot_number", "serial_number", "production_run_id", # #58
|
||||
]:
|
||||
val = request.args.get(key)
|
||||
if val is not None and val != "":
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""TieMeasureFlow Client Configuration."""
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||
@@ -10,6 +12,25 @@ load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
|
||||
|
||||
|
||||
def _read_app_version() -> str:
|
||||
"""Versione del prodotto = `[project] version` di pyproject.toml (#70).
|
||||
|
||||
Si risale dalla cartella del modulo: nel container il file sta in
|
||||
/app/pyproject.toml (Dockerfile.frontend), nel repo alla radice. Se non
|
||||
si trova o e' illeggibile torna "dev": il piede di pagina non deve mai
|
||||
rompere l'avvio.
|
||||
"""
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "pyproject.toml"
|
||||
if candidate.is_file():
|
||||
try:
|
||||
with candidate.open("rb") as fh:
|
||||
return str(tomllib.load(fh)["project"]["version"])
|
||||
except (OSError, tomllib.TOMLDecodeError, KeyError):
|
||||
return "dev"
|
||||
return "dev"
|
||||
|
||||
|
||||
class Config:
|
||||
"""Flask client configuration."""
|
||||
|
||||
@@ -17,6 +38,12 @@ class Config:
|
||||
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
|
||||
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
||||
|
||||
# Versione e commit mostrati nel piede di pagina (#70). Letti una volta
|
||||
# all'import. Il commit arriva dal build arg APP_COMMIT (.git non e' nel
|
||||
# contesto di build, vedi .dockerignore); facoltativo: vuoto = non mostrato.
|
||||
APP_VERSION: str = _read_app_version()
|
||||
APP_COMMIT: str = os.environ.get("APP_COMMIT", "")[:7]
|
||||
|
||||
# API Server connection
|
||||
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
||||
|
||||
|
||||
@@ -193,5 +193,9 @@ class APIClient:
|
||||
"""Return the list of active recipes assigned to the given station."""
|
||||
return self.get(f"/api/stations/by-code/{station_code}/recipes")
|
||||
|
||||
def get_station_by_code(self, station_code: str) -> dict[str, Any]:
|
||||
"""Return the station with the given code (camera_url / camera_source included)."""
|
||||
return self.get(f"/api/stations/by-code/{station_code}")
|
||||
|
||||
|
||||
api_client = APIClient()
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* image-crop.js — ritaglio di immagine/PDF prima dell'upload (#56).
|
||||
*
|
||||
* Cosa fa: ImageCrop.open(file) apre una modale con un rettangolo di ritaglio
|
||||
* trascinabile e ridimensionabile (mouse e touch, Pointer Events). Per un PDF
|
||||
* rasterizza la pagina scelta con pdf.js (>= 2000 px sul lato lungo).
|
||||
* Risolve: Blob PNG del ritaglio (a piena risoluzione) | il file originale
|
||||
* ("Usa originale") | null ("Annulla").
|
||||
* Come si usa: ImageCrop.configure({pdfSrc, workerSrc, labels}) una volta dal
|
||||
* template (URL di vendor/pdf e stringhe tradotte), poi `await ImageCrop.open(file)`.
|
||||
* Dipendenze: nessuna obbligatoria. pdf.js (gia' vendorizzato) viene caricato
|
||||
* al bisogno solo per i PDF; il ritaglio usa un canvas 2D semplice invece di
|
||||
* fabric.js perche' serve solo un rettangolo, non un editor di oggetti.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var cfg = {
|
||||
pdfSrc: '',
|
||||
workerSrc: '',
|
||||
labels: {
|
||||
title: 'Ritaglia immagine',
|
||||
page: 'Pagina',
|
||||
hint: 'Trascina il rettangolo o i suoi bordi per scegliere la zona da tenere.',
|
||||
pdfNote: 'Un PDF ritagliato viene salvato come immagine PNG.',
|
||||
confirm: 'Conferma ritaglio',
|
||||
original: 'Usa originale',
|
||||
cancel: 'Annulla',
|
||||
loading: 'Caricamento...',
|
||||
error: 'Impossibile aprire il file per il ritaglio.'
|
||||
}
|
||||
};
|
||||
|
||||
var MIN_SIDE = 16; // lato minimo del ritaglio, in px immagine
|
||||
var HANDLE = 12; // raggio di presa delle maniglie, in px schermo
|
||||
var PDF_MIN_LONG = 2000; // lato lungo minimo del raster PDF
|
||||
var PDF_MAX_LONG = 6000; // tetto per non esaurire la memoria del canvas
|
||||
|
||||
function configure(opts) {
|
||||
if (!opts) return;
|
||||
if (opts.pdfSrc) cfg.pdfSrc = opts.pdfSrc;
|
||||
if (opts.workerSrc) cfg.workerSrc = opts.workerSrc;
|
||||
if (opts.labels) for (var k in opts.labels) cfg.labels[k] = opts.labels[k];
|
||||
}
|
||||
|
||||
function isPdf(file) {
|
||||
return file.type === 'application/pdf' || /\.pdf$/i.test(file.name || '');
|
||||
}
|
||||
|
||||
function loadPdfJs() {
|
||||
if (global.pdfjsLib) return Promise.resolve(global.pdfjsLib);
|
||||
if (!cfg.pdfSrc) return Promise.reject(new Error('pdf.js non configurato'));
|
||||
return new Promise(function (resolve, reject) {
|
||||
var s = document.createElement('script');
|
||||
s.src = cfg.pdfSrc;
|
||||
s.onload = function () { resolve(global.pdfjsLib); };
|
||||
s.onerror = function () { reject(new Error('pdf.js non caricato')); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
function readImage(file) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var url = URL.createObjectURL(file);
|
||||
var img = new Image();
|
||||
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
|
||||
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('immagine illeggibile')); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function renderPdfPage(pdf, n) {
|
||||
return pdf.getPage(n).then(function (page) {
|
||||
var base = page.getViewport({ scale: 1 });
|
||||
var long = Math.max(base.width, base.height);
|
||||
var scale = Math.min(PDF_MAX_LONG / long, Math.max(2, PDF_MIN_LONG / long));
|
||||
var vp = page.getViewport({ scale: scale });
|
||||
var c = document.createElement('canvas');
|
||||
c.width = Math.round(vp.width);
|
||||
c.height = Math.round(vp.height);
|
||||
return page.render({ canvasContext: c.getContext('2d'), viewport: vp }).promise
|
||||
.then(function () { return c; });
|
||||
});
|
||||
}
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
function open(file) {
|
||||
var L = cfg.labels;
|
||||
return new Promise(function (resolve) {
|
||||
var src = null; // Image o canvas sorgente a piena risoluzione
|
||||
var rect = null; // {x,y,w,h} in px immagine
|
||||
var pdfDoc = null;
|
||||
var closed = false;
|
||||
|
||||
// ---- DOM (stile Maker: variabili CSS del tema + classi btn gia' presenti) ----
|
||||
var overlay = el('div', 'fixed inset-0 z-[100] flex items-center justify-center p-3 bg-black/60');
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
var box = el('div', 'rounded-xl shadow-xl flex flex-col w-full max-w-4xl max-h-full');
|
||||
box.style.cssText = 'background:var(--bg-primary,#fff);color:var(--text-primary,#111);border:1px solid var(--border-color,#ddd);';
|
||||
var head = el('div', 'flex items-center justify-between gap-3 px-4 py-3');
|
||||
head.appendChild(el('h3', 'font-semibold text-sm', L.title));
|
||||
var pageWrap = el('label', 'text-xs items-center gap-2');
|
||||
pageWrap.style.display = 'none';
|
||||
pageWrap.appendChild(document.createTextNode(L.page + ' '));
|
||||
var pageSel = el('select', 'tmf-input text-xs');
|
||||
pageSel.style.width = 'auto';
|
||||
pageWrap.appendChild(pageSel);
|
||||
head.appendChild(pageWrap);
|
||||
var body = el('div', 'px-4 flex-1 min-h-0 overflow-auto flex justify-center');
|
||||
var status = el('p', 'text-xs py-6', L.loading);
|
||||
body.appendChild(status);
|
||||
var canvas = el('canvas');
|
||||
canvas.style.cssText = 'max-width:100%;max-height:65vh;touch-action:none;cursor:crosshair;display:none;';
|
||||
body.appendChild(canvas);
|
||||
var foot = el('div', 'px-4 py-3 flex flex-wrap items-center justify-between gap-2');
|
||||
var note = el('p', 'text-xs');
|
||||
note.style.color = 'var(--text-muted,#666)';
|
||||
note.textContent = L.hint + (isPdf(file) ? ' ' + L.pdfNote : '');
|
||||
var btns = el('div', 'flex gap-2');
|
||||
var bCancel = el('button', 'btn btn-secondary text-xs', L.cancel);
|
||||
var bOrig = el('button', 'btn btn-secondary text-xs', L.original);
|
||||
var bOk = el('button', 'btn btn-primary text-xs', L.confirm);
|
||||
[bCancel, bOrig, bOk].forEach(function (b) { b.type = 'button'; btns.appendChild(b); });
|
||||
bOk.disabled = true;
|
||||
foot.appendChild(note);
|
||||
foot.appendChild(btns);
|
||||
box.appendChild(head); box.appendChild(body); box.appendChild(foot);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
function onKey(e) { if (e.key === 'Escape') finish(null); }
|
||||
function finish(value) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
document.removeEventListener('keydown', onKey);
|
||||
overlay.remove();
|
||||
resolve(value);
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
bCancel.onclick = function () { finish(null); };
|
||||
bOrig.onclick = function () { finish(file); };
|
||||
bOk.onclick = function () {
|
||||
if (!src || !rect) return;
|
||||
var out = document.createElement('canvas');
|
||||
out.width = Math.round(rect.w);
|
||||
out.height = Math.round(rect.h);
|
||||
out.getContext('2d').drawImage(src, Math.round(rect.x), Math.round(rect.y),
|
||||
out.width, out.height, 0, 0, out.width, out.height);
|
||||
out.toBlob(function (blob) { finish(blob || file); }, 'image/png');
|
||||
};
|
||||
|
||||
// ---- disegno ----
|
||||
function sw() { return src.width || src.naturalWidth; }
|
||||
function sh() { return src.height || src.naturalHeight; }
|
||||
function handlePoints(r) {
|
||||
var mx = r.x + r.w / 2, my = r.y + r.h / 2, ex = r.x + r.w, ey = r.y + r.h;
|
||||
return [
|
||||
{ n: 'nw', x: r.x, y: r.y }, { n: 'n', x: mx, y: r.y }, { n: 'ne', x: ex, y: r.y },
|
||||
{ n: 'e', x: ex, y: my }, { n: 'se', x: ex, y: ey }, { n: 's', x: mx, y: ey },
|
||||
{ n: 'sw', x: r.x, y: ey }, { n: 'w', x: r.x, y: my }
|
||||
];
|
||||
}
|
||||
function draw() {
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(src, 0, 0, canvas.width, canvas.height);
|
||||
var r = rect; // il canvas di visualizzazione ha la risoluzione della sorgente
|
||||
// velo scuro fuori dal ritaglio (4 fasce)
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.fillRect(0, 0, canvas.width, r.y);
|
||||
ctx.fillRect(0, r.y + r.h, canvas.width, canvas.height - r.y - r.h);
|
||||
ctx.fillRect(0, r.y, r.x, r.h);
|
||||
ctx.fillRect(r.x + r.w, r.y, canvas.width - r.x - r.w, r.h);
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.lineWidth = Math.max(2, canvas.width / 400);
|
||||
ctx.strokeRect(r.x, r.y, r.w, r.h);
|
||||
ctx.fillStyle = '#3b82f6';
|
||||
var hs = Math.max(8, canvas.width / 80);
|
||||
handlePoints(r).forEach(function (p) { ctx.fillRect(p.x - hs / 2, p.y - hs / 2, hs, hs); });
|
||||
}
|
||||
function setSource(s) {
|
||||
src = s;
|
||||
canvas.width = sw();
|
||||
canvas.height = sh();
|
||||
rect = { x: 0, y: 0, w: sw(), h: sh() }; // all'inizio: tutta l'immagine
|
||||
status.style.display = 'none';
|
||||
canvas.style.display = 'block';
|
||||
bOk.disabled = false;
|
||||
draw();
|
||||
}
|
||||
|
||||
// ---- interazione ----
|
||||
var drag = null;
|
||||
function toImg(e) {
|
||||
var b = canvas.getBoundingClientRect();
|
||||
return { x: (e.clientX - b.left) * sw() / b.width, y: (e.clientY - b.top) * sh() / b.height,
|
||||
k: sw() / b.width }; // k = px immagine per px schermo
|
||||
}
|
||||
function hit(p) {
|
||||
var best = null, bd = HANDLE * p.k;
|
||||
handlePoints(rect).forEach(function (h) {
|
||||
var d = Math.max(Math.abs(h.x - p.x), Math.abs(h.y - p.y));
|
||||
if (d <= bd) { bd = d; best = h.n; }
|
||||
});
|
||||
if (best) return best;
|
||||
if (p.x > rect.x && p.x < rect.x + rect.w && p.y > rect.y && p.y < rect.y + rect.h) return 'move';
|
||||
return 'new';
|
||||
}
|
||||
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
||||
canvas.addEventListener('pointerdown', function (e) {
|
||||
if (!src) return;
|
||||
var p = toImg(e);
|
||||
var mode = hit(p);
|
||||
drag = { mode: mode, sx: p.x, sy: p.y, r0: { x: rect.x, y: rect.y, w: rect.w, h: rect.h } };
|
||||
if (mode === 'new') { // nuovo rettangolo: parte dal punto di tocco
|
||||
drag.r0 = { x: clamp(p.x, 0, sw()), y: clamp(p.y, 0, sh()), w: 0, h: 0 };
|
||||
}
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
canvas.addEventListener('pointermove', function (e) {
|
||||
if (!src) return;
|
||||
var p = toImg(e);
|
||||
if (!drag) {
|
||||
var m = hit(p);
|
||||
canvas.style.cursor = m === 'move' ? 'move' : m === 'new' ? 'crosshair'
|
||||
: (m.length === 2 ? (m === 'nw' || m === 'se' ? 'nwse-resize' : 'nesw-resize')
|
||||
: (m === 'n' || m === 's' ? 'ns-resize' : 'ew-resize'));
|
||||
return;
|
||||
}
|
||||
var dx = p.x - drag.sx, dy = p.y - drag.sy, r0 = drag.r0, W = sw(), H = sh();
|
||||
if (drag.mode === 'move') {
|
||||
rect.x = clamp(r0.x + dx, 0, W - r0.w);
|
||||
rect.y = clamp(r0.y + dy, 0, H - r0.h);
|
||||
} else {
|
||||
var x1 = r0.x, y1 = r0.y, x2 = r0.x + r0.w, y2 = r0.y + r0.h, m2 = drag.mode;
|
||||
if (m2 === 'new') { x2 = clamp(p.x, 0, W); y2 = clamp(p.y, 0, H); }
|
||||
else {
|
||||
if (m2.indexOf('w') >= 0) x1 = clamp(r0.x + dx, 0, x2 - MIN_SIDE);
|
||||
if (m2.indexOf('e') >= 0) x2 = clamp(x2 + dx, x1 + MIN_SIDE, W);
|
||||
if (m2.indexOf('n') >= 0) y1 = clamp(r0.y + dy, 0, y2 - MIN_SIDE);
|
||||
if (m2.indexOf('s') >= 0) y2 = clamp(y2 + dy, y1 + MIN_SIDE, H);
|
||||
}
|
||||
rect = { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
|
||||
}
|
||||
bOk.disabled = rect.w < MIN_SIDE || rect.h < MIN_SIDE; // ritaglio troppo piccolo: non confermabile
|
||||
draw();
|
||||
e.preventDefault();
|
||||
});
|
||||
function up() { drag = null; }
|
||||
canvas.addEventListener('pointerup', up);
|
||||
canvas.addEventListener('pointercancel', up);
|
||||
|
||||
// ---- caricamento sorgente ----
|
||||
function fail(err) {
|
||||
console.error('ImageCrop:', err);
|
||||
status.style.display = '';
|
||||
status.textContent = L.error;
|
||||
canvas.style.display = 'none';
|
||||
bOk.disabled = true;
|
||||
}
|
||||
if (isPdf(file)) {
|
||||
loadPdfJs().then(function (lib) {
|
||||
if (cfg.workerSrc) lib.GlobalWorkerOptions.workerSrc = cfg.workerSrc;
|
||||
return file.arrayBuffer().then(function (buf) { return lib.getDocument({ data: buf }).promise; });
|
||||
}).then(function (pdf) {
|
||||
pdfDoc = pdf;
|
||||
if (pdf.numPages > 1) {
|
||||
for (var i = 1; i <= pdf.numPages; i++) {
|
||||
var o = el('option', '', String(i)); o.value = String(i); pageSel.appendChild(o);
|
||||
}
|
||||
pageWrap.style.display = 'flex';
|
||||
}
|
||||
return renderPdfPage(pdf, 1);
|
||||
}).then(setSource).catch(fail);
|
||||
pageSel.onchange = function () {
|
||||
bOk.disabled = true;
|
||||
renderPdfPage(pdfDoc, parseInt(pageSel.value, 10)).then(setSource).catch(fail);
|
||||
};
|
||||
} else {
|
||||
readImage(file).then(setSource).catch(fail);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
global.ImageCrop = { open: open, configure: configure };
|
||||
})(window);
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Login Operatore: nome + PIN a 4 cifre (#59).
|
||||
* Componente Alpine globale `pinLogin()`, usato da templates/auth/login.html.
|
||||
* Contratto: GET /auth/pin-users -> [{id, display_name}];
|
||||
* POST /auth/login-pin {user_id, pin} -> {ok, redirect} | {error, detail}. */
|
||||
function pinLogin() {
|
||||
return {
|
||||
mode: 'pin', // vista Operatore di default; se l'elenco e' vuoto/fallisce passa a 'password'
|
||||
users: [],
|
||||
selected: null,
|
||||
pin: '',
|
||||
error: '',
|
||||
busy: false,
|
||||
|
||||
init: function () {
|
||||
var self = this;
|
||||
fetch(window.TMF_PIN.usersUrl, { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : []; })
|
||||
.then(function (list) {
|
||||
self.users = Array.isArray(list) ? list : [];
|
||||
if (!self.users.length) self.mode = 'password'; // elenco vuoto: form password
|
||||
})
|
||||
.catch(function () { self.users = []; self.mode = 'password'; });
|
||||
},
|
||||
select: function (u) { this.selected = u; this.pin = ''; this.error = ''; },
|
||||
back: function () { this.selected = null; this.pin = ''; this.error = ''; },
|
||||
del: function () { this.pin = this.pin.slice(0, -1); },
|
||||
press: function (d) {
|
||||
if (this.busy || this.pin.length >= 4) return;
|
||||
this.pin += d;
|
||||
this.error = '';
|
||||
if (this.pin.length === 4) this.submit(); // invio automatico alla quarta cifra
|
||||
},
|
||||
submit: function () {
|
||||
var self = this;
|
||||
var csrf = document.querySelector('meta[name=csrf-token]');
|
||||
self.busy = true;
|
||||
fetch(window.TMF_PIN.loginUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRFToken': (csrf && csrf.content) || ''
|
||||
},
|
||||
body: JSON.stringify({ user_id: self.selected.id, pin: self.pin })
|
||||
})
|
||||
.then(function (r) { return r.json().catch(function () { return {}; }); })
|
||||
.then(function (d) {
|
||||
if (d.ok && d.redirect) { window.location.href = d.redirect; return; }
|
||||
self.error = d.detail || window.TMF_PIN.msgNet; // 401 PIN errato, 423 blocco con ora
|
||||
self.pin = '';
|
||||
self.busy = false;
|
||||
})
|
||||
.catch(function () {
|
||||
self.error = window.TMF_PIN.msgNet;
|
||||
self.pin = '';
|
||||
self.busy = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Task results (#53 #54 #55) - Alpine component for the tasks that record a result.
|
||||
*
|
||||
* - «check» task (#54): the operator answers OK or NOK, with a note (mandatory for NOK).
|
||||
* - «note» task with `fields` (#55): one input per field, an optional attachment
|
||||
* (image / PDF) for the fields that ask for it, outcome 'done'.
|
||||
*
|
||||
* The same component serves the task being executed (one form) and the panel shown
|
||||
* at the end of production (one form per at_close task), so the field rules live here
|
||||
* once. The page sets `window.__taskResultsCfg` (urls + labels) and the component's
|
||||
* `ctx` (production run id + callbacks) from its x-init, the way visionExecute() does.
|
||||
*
|
||||
* Plain functions only (no framework imports) so tests/js/test_task_results.js can
|
||||
* run them under node with a stubbed fetch.
|
||||
*/
|
||||
function taskResultForm() {
|
||||
return {
|
||||
// Set by the page's x-init: () => ({ runId: int|null, done: () => void })
|
||||
ctx: null,
|
||||
forms: [],
|
||||
working: false,
|
||||
error: '',
|
||||
// true once a result was recorded during this page view (shows «Prosegui»)
|
||||
justRecorded: false,
|
||||
// Panel open state (used by the end-of-production panel only)
|
||||
open: false,
|
||||
|
||||
cfg() { return window.__taskResultsCfg || { labels: {} }; },
|
||||
label(key, fallback) {
|
||||
return (this.cfg().labels && this.cfg().labels[key]) || fallback;
|
||||
},
|
||||
runId() {
|
||||
const c = this.ctx ? this.ctx() : null;
|
||||
return c && c.runId ? c.runId : null;
|
||||
},
|
||||
|
||||
/* One form per task: values start from the field's default, the way the maker set it. */
|
||||
makeForm(task) {
|
||||
const fields = Array.isArray(task.fields) ? task.fields : [];
|
||||
const values = {};
|
||||
fields.forEach((f) => { values[f.key] = (f.default === null || f.default === undefined) ? '' : String(f.default); });
|
||||
return {
|
||||
task_id: task.id,
|
||||
title: task.title || '',
|
||||
directive: task.directive || '',
|
||||
type: task.task_type,
|
||||
fields: fields,
|
||||
values: values,
|
||||
files: {},
|
||||
fileNames: {},
|
||||
note: '',
|
||||
outcome: '',
|
||||
error: '',
|
||||
result: null,
|
||||
recorded: false,
|
||||
};
|
||||
},
|
||||
|
||||
/* The single form of the task on screen. */
|
||||
initForTask(task) {
|
||||
if (!task) return;
|
||||
const isCheck = task.task_type === 'check';
|
||||
const isNoteWithFields = task.task_type === 'note' && Array.isArray(task.fields) && task.fields.length > 0;
|
||||
if (!isCheck && !isNoteWithFields) return;
|
||||
this.forms = [this.makeForm(task)];
|
||||
this.loadLast();
|
||||
},
|
||||
|
||||
pickFile(form, key, ev) {
|
||||
const f = ev && ev.target && ev.target.files ? ev.target.files[0] : null;
|
||||
if (f) { form.files[key] = f; form.fileNames[key] = f.name; }
|
||||
else { delete form.files[key]; form.fileNames[key] = ''; }
|
||||
},
|
||||
|
||||
csrf() {
|
||||
const m = typeof document !== 'undefined' ? document.querySelector('meta[name=csrf-token]') : null;
|
||||
return (m && m.content) || '';
|
||||
},
|
||||
|
||||
/* Fetch + JSON with the page's usual error reading: the detail of the server is
|
||||
what the operator sees; an HTML answer (expired CSRF / session) means «reload». */
|
||||
async call(url, opts) {
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, opts);
|
||||
} catch (e) {
|
||||
return { ok: false, detail: this.label('connection', 'Errore di connessione') };
|
||||
}
|
||||
const data = await resp.json().catch(() => null);
|
||||
if (!data || data.csrf_expired || data.session_expired) {
|
||||
return { ok: false, detail: this.label('sessionExpired', 'Sessione scaduta: ricaricare la pagina') };
|
||||
}
|
||||
if (!resp.ok) {
|
||||
return { ok: false, detail: data.detail || this.label('serverError', 'Errore di comunicazione con il server') };
|
||||
}
|
||||
return { ok: true, data: data };
|
||||
},
|
||||
|
||||
/* The last result of this task: in the run under way if there is one, otherwise
|
||||
the one given in this browser session (no run = nothing on the server to ask).
|
||||
Only shown: the buttons stay active, a loop gives a new result every cycle. */
|
||||
async loadLast() {
|
||||
const form = this.forms[0];
|
||||
if (!form) return;
|
||||
const rid = this.runId();
|
||||
if (rid && this.cfg().listUrl) {
|
||||
const r = await this.call(
|
||||
this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid) +
|
||||
'&task_id=' + encodeURIComponent(form.task_id));
|
||||
if (r.ok && Array.isArray(r.data) && r.data.length) {
|
||||
form.result = r.data[r.data.length - 1];
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem('tmf-task-result-' + form.task_id);
|
||||
if (raw && !rid) form.result = JSON.parse(raw);
|
||||
} catch (e) { /* storage may be unavailable: the result is simply not shown */ }
|
||||
},
|
||||
|
||||
/* Validation before anything goes on the wire. Returns the message or ''. */
|
||||
validate(form, outcome) {
|
||||
if (form.type === 'check') {
|
||||
if (outcome === 'nok' && !(form.note || '').trim()) {
|
||||
return this.label('nokNeedsNote', 'Per un NOK serve la nota');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
for (const f of form.fields) {
|
||||
const v = form.values[f.key];
|
||||
if (f.type === 'number' && v !== '' && v !== null && v !== undefined &&
|
||||
!isFinite(Number(String(v).replace(',', '.')))) {
|
||||
return this.label('notANumber', 'Valore non numerico') + ': ' + f.label;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
/* Upload the attachments of a form, then POST the result.
|
||||
Returns true when the result is recorded (forms already recorded are skipped). */
|
||||
async record(form, outcome) {
|
||||
if (form.recorded) return true;
|
||||
form.error = this.validate(form, outcome);
|
||||
if (form.error) return false;
|
||||
|
||||
const attachments = [];
|
||||
for (const f of form.fields) {
|
||||
const file = form.files[f.key];
|
||||
if (!f.attachment || !file) continue;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file, file.name);
|
||||
const up = await this.call(this.cfg().uploadUrl, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': this.csrf() }, body: fd,
|
||||
});
|
||||
if (!up.ok) { form.error = up.detail; return false; }
|
||||
// The backend answers {file_path, ...}: that path is what the result keeps.
|
||||
attachments.push({ key: f.key, path: up.data.file_path || up.data.path, name: file.name });
|
||||
}
|
||||
|
||||
let values = null;
|
||||
if (form.type === 'note' && form.fields.length) {
|
||||
values = {};
|
||||
form.fields.forEach((f) => {
|
||||
const v = form.values[f.key];
|
||||
if (v === '' || v === null || v === undefined) return;
|
||||
values[f.key] = f.type === 'number' ? String(v).replace(',', '.') : String(v);
|
||||
});
|
||||
}
|
||||
|
||||
const body = {
|
||||
task_id: form.task_id,
|
||||
production_run_id: this.runId(),
|
||||
outcome: form.type === 'check' ? outcome : 'done',
|
||||
note: (form.note || '').trim() || null,
|
||||
values: values,
|
||||
attachments: attachments.length ? attachments : null,
|
||||
};
|
||||
const r = await this.call(this.cfg().saveUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrf() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) { form.error = r.detail; return false; }
|
||||
form.result = r.data;
|
||||
form.recorded = true;
|
||||
try {
|
||||
window.sessionStorage.setItem('tmf-task-result-' + form.task_id, JSON.stringify(r.data));
|
||||
} catch (e) { /* storage may be unavailable */ }
|
||||
return true;
|
||||
},
|
||||
|
||||
/* Task on screen: OK / NOK button, or «Completato» of a note with fields. */
|
||||
async submit(outcome) {
|
||||
const form = this.forms[0];
|
||||
if (!form || this.working) return;
|
||||
this.working = true;
|
||||
this.error = '';
|
||||
const ok = await this.record(form, outcome);
|
||||
this.working = false;
|
||||
if (!ok) return;
|
||||
this.justRecorded = true;
|
||||
this.proceed();
|
||||
},
|
||||
|
||||
/* Hand over to the page: close the cycle / go on. Also bound to «Prosegui», for
|
||||
when that step failed after the result was already recorded. */
|
||||
proceed() {
|
||||
const c = this.ctx ? this.ctx() : null;
|
||||
if (c && c.done) c.done();
|
||||
},
|
||||
|
||||
/* The result as the operator reads it. */
|
||||
resultLabel(result) {
|
||||
if (!result) return '';
|
||||
if (result.outcome === 'ok') return this.label('outcomeOk', 'OK');
|
||||
if (result.outcome === 'nok') return this.label('outcomeNok', 'NOK');
|
||||
return this.label('outcomeDone', 'Registrato');
|
||||
},
|
||||
|
||||
// ---- End of production panel (#55) ----
|
||||
|
||||
/* Open the panel for the at_close tasks of the run. A task that already has a
|
||||
result in this run (page reloaded in between) is not asked again. */
|
||||
async openClose(ids) {
|
||||
this.error = '';
|
||||
this.working = true;
|
||||
this.open = true;
|
||||
this.forms = [];
|
||||
const rid = this.runId();
|
||||
let done = [];
|
||||
if (rid && this.cfg().listUrl) {
|
||||
const r = await this.call(this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid));
|
||||
if (r.ok && Array.isArray(r.data)) done = r.data.map((x) => x.task_id);
|
||||
}
|
||||
const forms = [];
|
||||
for (const id of ids) {
|
||||
const r = await this.call(this.cfg().taskUrlTpl.replace('/0', '/' + id));
|
||||
if (!r.ok) { this.error = r.detail; this.working = false; return; }
|
||||
const form = this.makeForm(r.data);
|
||||
if (done.indexOf(id) !== -1) form.recorded = true;
|
||||
forms.push(form);
|
||||
}
|
||||
this.forms = forms;
|
||||
this.working = false;
|
||||
// Nothing left to ask: straight on to the capoturno.
|
||||
if (forms.every((f) => f.recorded)) this.finishClose();
|
||||
},
|
||||
|
||||
cancelClose() { this.open = false; this.forms = []; this.error = ''; },
|
||||
|
||||
async submitClose() {
|
||||
if (this.working) return;
|
||||
this.working = true;
|
||||
this.error = '';
|
||||
let allOk = true;
|
||||
for (const form of this.forms) {
|
||||
// Keep going after a failure: each form shows its own error, and the ones
|
||||
// already recorded are not sent again on the next try.
|
||||
if (!(await this.record(form, 'done'))) allOk = false;
|
||||
}
|
||||
this.working = false;
|
||||
if (allOk) this.finishClose();
|
||||
},
|
||||
|
||||
finishClose() {
|
||||
this.open = false;
|
||||
this.proceed();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = { taskResultForm };
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Vision execute - Alpine component for camera_measure / dxf_compare tasks (#46).
|
||||
*
|
||||
* The operator takes (or picks) an image, `acquire(blob)` sends it to the Flask
|
||||
* proxy, and the result - quote table, outcome, failures, overlay - is shown on
|
||||
* the image. `acquire(blob)` is the single entry point on purpose: the agent
|
||||
* camera (#3) will call it with its own blob and reuse everything below.
|
||||
*
|
||||
* Config comes from window.__visionCfg (set by the template):
|
||||
* executeUrl, imageUrlTpl (with a 0 placeholder for the result id), taskId,
|
||||
* taskType, subtasks, labels.
|
||||
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
||||
* which the template binds to the parent task component.
|
||||
*/
|
||||
function visionExecute() {
|
||||
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||
|
||||
return {
|
||||
busy: false,
|
||||
grabbing: false, // frame requested from the station camera (#72)
|
||||
error: '',
|
||||
result: null, // last execute response
|
||||
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
|
||||
|
||||
// Entry point shared with the future agent camera.
|
||||
async acquire(blob) {
|
||||
if (!blob || this.busy) return;
|
||||
const cfg = window.__visionCfg || {};
|
||||
this.busy = true;
|
||||
this.error = '';
|
||||
this.result = null;
|
||||
// Show the picked image at once: the overlay is drawn on top when the result arrives.
|
||||
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||
this.imageSrc = URL.createObjectURL(blob);
|
||||
try {
|
||||
const c = this.ctx() || {};
|
||||
const fd = new FormData();
|
||||
fd.append('task_id', cfg.taskId);
|
||||
fd.append('task_type', cfg.taskType || '');
|
||||
fd.append('image', blob, blob.name || 'capture.jpg');
|
||||
fd.append('lot_number', c.lot || '');
|
||||
fd.append('serial_number', c.serial || '');
|
||||
if (c.runId) fd.append('production_run_id', c.runId);
|
||||
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const resp = await fetch(cfg.executeUrl, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || body.error) {
|
||||
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
}
|
||||
this.result = body;
|
||||
// Draw once Alpine has rendered the canvas.
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
} catch (e) {
|
||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Station camera (#72): the Flask proxy answers a PNG, which goes through
|
||||
// acquire() like a picked file. Errors show as the execute ones do.
|
||||
async grab() {
|
||||
const cfg = window.__visionCfg || {};
|
||||
if (this.busy || this.grabbing || !cfg.grabUrl) return;
|
||||
this.grabbing = true;
|
||||
this.error = '';
|
||||
let blob = null;
|
||||
try {
|
||||
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const resp = await fetch(cfg.grabUrl, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
}
|
||||
blob = await resp.blob();
|
||||
} catch (e) {
|
||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
} finally {
|
||||
this.grabbing = false;
|
||||
}
|
||||
if (blob) await this.acquire(blob);
|
||||
},
|
||||
|
||||
// Input change: take the file and allow picking the same one again.
|
||||
onPick(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
if (f) this.acquire(f);
|
||||
},
|
||||
|
||||
// Image shown: the saved one from the server when available, else the local one.
|
||||
get shownImage() {
|
||||
const cfg = window.__visionCfg || {};
|
||||
if (this.result && this.result.vision_result_id && cfg.imageUrlTpl) {
|
||||
return cfg.imageUrlTpl.replace('/0/', '/' + this.result.vision_result_id + '/');
|
||||
}
|
||||
return this.imageSrc;
|
||||
},
|
||||
|
||||
// Quote rows: server measurements joined with the subtask names.
|
||||
get rows() {
|
||||
const cfg = window.__visionCfg || {};
|
||||
const subs = cfg.subtasks || [];
|
||||
return ((this.result && this.result.measurements) || []).map((m) => {
|
||||
const st = subs.find((s) => s.id === m.subtask_id) || {};
|
||||
return {
|
||||
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
|
||||
value: m.value,
|
||||
unit: st.unit || 'mm',
|
||||
status: m.pass_fail,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
statusClass(s) {
|
||||
// Same palette as the measurement markers of the manual flow.
|
||||
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
|
||||
if (s === 'fail') return 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40';
|
||||
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
|
||||
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
|
||||
},
|
||||
|
||||
// Canvas is sized to the natural image, so overlay coordinates (image px)
|
||||
// are used as they are and CSS scales the whole thing to the displayed size.
|
||||
drawOverlay() {
|
||||
const img = this.$refs.img, cv = this.$refs.overlay;
|
||||
if (!img || !cv || !img.naturalWidth) return;
|
||||
cv.width = img.naturalWidth;
|
||||
cv.height = img.naturalHeight;
|
||||
const g = cv.getContext('2d');
|
||||
g.clearRect(0, 0, cv.width, cv.height);
|
||||
g.lineWidth = Math.max(2, cv.width / 400);
|
||||
g.lineJoin = 'round';
|
||||
for (const item of ((this.result && this.result.overlay) || [])) {
|
||||
const rows = item.rows || [], cols = item.cols || [];
|
||||
if (!rows.length || rows.length !== cols.length) continue;
|
||||
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
|
||||
g.beginPath();
|
||||
g.moveTo(cols[0], rows[0]);
|
||||
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
|
||||
g.stroke();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+132
-219
@@ -1,18 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Gestione Utenti') }} - TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Pannello utenti: incluso nella tab «Utenti» di Impostazioni (#50) #}
|
||||
<script>window.__users = {{ users|tojson }};</script>
|
||||
|
||||
<div class="tmf-page"
|
||||
x-data="userManagement(window.__users)">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Utenti') }}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci gli utenti del sistema') }}</p>
|
||||
</div>
|
||||
<div x-data="userManagement(window.__users)">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
|
||||
@@ -66,6 +55,10 @@
|
||||
<template x-if="user.is_admin">
|
||||
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">Admin</span>
|
||||
</template>
|
||||
<!-- #59: indicatore PIN impostato (solo se il backend manda has_pin) -->
|
||||
<template x-if="!user.is_admin && user.has_pin === true">
|
||||
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300">{{ _('PIN impostato') }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -102,6 +95,16 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- #51: reset password dall'admin -->
|
||||
<button @click="openReset(user)" class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="{{ _('Reset password')|tojson_attr }}">
|
||||
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- #59: imposta/reset PIN (non per gli admin: il backend risponde 422) -->
|
||||
<template x-if="!user.is_admin">
|
||||
<button @click="openPin(user)" class="px-1.5 py-1 rounded-lg text-[11px] font-semibold text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="user.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}">PIN</button>
|
||||
</template>
|
||||
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
|
||||
:class="user.active
|
||||
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
@@ -215,6 +218,20 @@
|
||||
minlength="6">
|
||||
</div>
|
||||
|
||||
<!-- #59: PIN facoltativo, solo in creazione di un non admin -->
|
||||
<template x-if="!isEditing && !form.is_admin">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
{{ _('PIN') }}
|
||||
<span class="font-normal text-[var(--text-secondary)]"> ({{ _('facoltativo, 4 cifre') }})</span>
|
||||
</label>
|
||||
<input type="text" x-model="form.pin" inputmode="numeric" maxlength="4" autocomplete="off"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="1234">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Roles -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
|
||||
@@ -332,210 +349,106 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #51: Modal reset password (la password attuale non si mostra mai) -->
|
||||
<div x-show="showReset" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape.window="closeReset()">
|
||||
<div class="absolute inset-0 bg-black/50" @click="closeReset()"></div>
|
||||
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{{ _('Reset password') }}: <span x-text="resetUser?.username"></span>
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
{{ _('Le sessioni aperte dell\'utente verranno chiuse e dovrà accedere con la nuova password.') }}
|
||||
</p>
|
||||
<template x-if="!resetDone">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuova password (minimo 8 caratteri)') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" x-model="resetPw" autocomplete="off"
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
|
||||
<button @click="generateResetPw()" type="button"
|
||||
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
|
||||
{{ _('Genera') }}
|
||||
</button>
|
||||
</div>
|
||||
<template x-if="resetError">
|
||||
<p class="mt-2 text-sm text-red-600" x-text="resetError"></p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="resetDone">
|
||||
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
|
||||
<p class="text-sm text-emerald-700 dark:text-emerald-300">{{ _('Password cambiata. Annotala ora: non verrà mostrata di nuovo.') }}</p>
|
||||
<p class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="resetPw"></p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button @click="closeReset()"
|
||||
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
|
||||
<span x-text="resetDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
|
||||
</button>
|
||||
<button x-show="!resetDone" @click="confirmReset()" :disabled="saving"
|
||||
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
|
||||
{{ _('Imposta password') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #59: Modal imposta / reset / togli PIN (il PIN attuale non si mostra mai) -->
|
||||
<div x-show="showPin" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape.window="closePin()">
|
||||
<div class="absolute inset-0 bg-black/50" @click="closePin()"></div>
|
||||
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
<span x-text="pinUser?.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}"></span>: <span x-text="pinUser?.username"></span>
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
{{ _('Il PIN serve per accedere alla postazione e, per il capoturno, per autorizzare le azioni.') }}
|
||||
</p>
|
||||
<template x-if="!pinDone">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuovo PIN (4 cifre)') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" x-model="pinValue" inputmode="numeric" maxlength="4" autocomplete="off"
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
|
||||
<button @click="generatePin()" type="button"
|
||||
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
|
||||
{{ _('Genera') }}
|
||||
</button>
|
||||
</div>
|
||||
<template x-if="pinError">
|
||||
<p class="mt-2 text-sm text-red-600" x-text="pinError"></p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="pinDone">
|
||||
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
|
||||
<p class="text-sm text-emerald-700 dark:text-emerald-300" x-text="pinRemoved ? {{ _('PIN tolto.')|tojson_attr }} : {{ _('PIN impostato. Annotalo ora: non verrà mostrato di nuovo.')|tojson_attr }}"></p>
|
||||
<p x-show="!pinRemoved" class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="pinValue"></p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex items-center justify-between gap-3 mt-5">
|
||||
<div>
|
||||
<button x-show="!pinDone && pinUser?.has_pin" @click="removePin()" :disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50">
|
||||
{{ _('Togli PIN') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button @click="closePin()"
|
||||
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
|
||||
<span x-text="pinDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
|
||||
</button>
|
||||
<button x-show="!pinDone" @click="confirmPin()" :disabled="saving"
|
||||
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
|
||||
{{ _('Imposta PIN') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function userManagement(initialUsers) {
|
||||
return {
|
||||
users: initialUsers || [],
|
||||
search: '',
|
||||
showModal: false,
|
||||
showToggleConfirm: false,
|
||||
isEditing: false,
|
||||
editingUserId: null,
|
||||
toggleUser: null,
|
||||
saving: false,
|
||||
errorMsg: '',
|
||||
form: {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
},
|
||||
|
||||
get csrfToken() {
|
||||
const meta = document.querySelector('meta[name=csrf-token]');
|
||||
return meta ? meta.content : '';
|
||||
},
|
||||
|
||||
get filteredUsers() {
|
||||
if (!this.search.trim()) return this.users;
|
||||
const q = this.search.toLowerCase();
|
||||
return this.users.filter(u =>
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
|
||||
(u.email && u.email.toLowerCase().includes(q)) ||
|
||||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
|
||||
);
|
||||
},
|
||||
|
||||
openCreateModal() {
|
||||
this.isEditing = false;
|
||||
this.editingUserId = null;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
openEditModal(user) {
|
||||
this.isEditing = true;
|
||||
this.editingUserId = user.id;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: user.username,
|
||||
display_name: user.display_name || '',
|
||||
email: user.email || '',
|
||||
password: '',
|
||||
roles: [...(user.roles || [])],
|
||||
is_admin: user.is_admin || false,
|
||||
language_pref: user.language_pref || 'it',
|
||||
theme_pref: user.theme_pref || 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.errorMsg = '';
|
||||
},
|
||||
|
||||
async saveUser() {
|
||||
this.saving = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
try {
|
||||
if (this.isEditing) {
|
||||
// Update user
|
||||
const data = {
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nel salvataggio';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.editingUserId);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
// Change password if provided
|
||||
if (this.form.password) {
|
||||
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ password: this.form.password })
|
||||
});
|
||||
if (!pwResp.ok) {
|
||||
const pwResult = await pwResp.json();
|
||||
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
if (!this.form.username || !this.form.display_name || !this.form.password) {
|
||||
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
username: this.form.username,
|
||||
password: this.form.password,
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch('/admin/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nella creazione';
|
||||
return;
|
||||
}
|
||||
|
||||
this.users.push(result);
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
toggleActive(user) {
|
||||
this.toggleUser = user;
|
||||
this.showToggleConfirm = true;
|
||||
},
|
||||
|
||||
async confirmToggle() {
|
||||
if (!this.toggleUser) return;
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const newActive = !this.toggleUser.active;
|
||||
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ active: newActive })
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
alert(result.detail || 'Errore');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
this.showToggleConfirm = false;
|
||||
this.toggleUser = null;
|
||||
} catch (e) {
|
||||
alert('{{ _("Errore di connessione al server") }}');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,355 @@
|
||||
<script>
|
||||
function userManagement(initialUsers) {
|
||||
return {
|
||||
users: initialUsers || [],
|
||||
search: '',
|
||||
showModal: false,
|
||||
showToggleConfirm: false,
|
||||
// #51: stato del reset password
|
||||
showReset: false,
|
||||
resetUser: null,
|
||||
resetPw: '',
|
||||
resetDone: false,
|
||||
resetError: '',
|
||||
// #59: stato del modal PIN
|
||||
showPin: false,
|
||||
pinUser: null,
|
||||
pinValue: '',
|
||||
pinDone: false,
|
||||
pinRemoved: false,
|
||||
pinError: '',
|
||||
isEditing: false,
|
||||
editingUserId: null,
|
||||
toggleUser: null,
|
||||
saving: false,
|
||||
errorMsg: '',
|
||||
form: {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
pin: '', // #59: PIN facoltativo alla creazione
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
},
|
||||
|
||||
get csrfToken() {
|
||||
const meta = document.querySelector('meta[name=csrf-token]');
|
||||
return meta ? meta.content : '';
|
||||
},
|
||||
|
||||
get filteredUsers() {
|
||||
if (!this.search.trim()) return this.users;
|
||||
const q = this.search.toLowerCase();
|
||||
return this.users.filter(u =>
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
|
||||
(u.email && u.email.toLowerCase().includes(q)) ||
|
||||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
|
||||
);
|
||||
},
|
||||
|
||||
openCreateModal() {
|
||||
this.isEditing = false;
|
||||
this.editingUserId = null;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
pin: '', // #59
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
openEditModal(user) {
|
||||
this.isEditing = true;
|
||||
this.editingUserId = user.id;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: user.username,
|
||||
display_name: user.display_name || '',
|
||||
email: user.email || '',
|
||||
password: '',
|
||||
roles: [...(user.roles || [])],
|
||||
is_admin: user.is_admin || false,
|
||||
language_pref: user.language_pref || 'it',
|
||||
theme_pref: user.theme_pref || 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.errorMsg = '';
|
||||
},
|
||||
|
||||
async saveUser() {
|
||||
this.saving = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
try {
|
||||
if (this.isEditing) {
|
||||
// Update user
|
||||
const data = {
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nel salvataggio';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.editingUserId);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
// Change password if provided
|
||||
if (this.form.password) {
|
||||
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ password: this.form.password })
|
||||
});
|
||||
if (!pwResp.ok) {
|
||||
const pwResult = await pwResp.json();
|
||||
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
if (!this.form.username || !this.form.display_name || !this.form.password) {
|
||||
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
|
||||
return;
|
||||
}
|
||||
|
||||
// #59: PIN facoltativo, 4 cifre, solo per i non admin
|
||||
const pin = this.form.is_admin ? '' : (this.form.pin || '').trim();
|
||||
if (pin && !/^\d{4}$/.test(pin)) {
|
||||
this.errorMsg = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
|
||||
return;
|
||||
}
|
||||
const data = {
|
||||
username: this.form.username,
|
||||
password: this.form.password,
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch('/admin/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nella creazione';
|
||||
return;
|
||||
}
|
||||
|
||||
this.users.push(result);
|
||||
|
||||
// #59: l'utente esiste gia', il PIN si imposta con la chiamata dedicata
|
||||
if (pin) {
|
||||
const pinResp = await fetch(`/admin/api/users/${result.id}/pin`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ pin: pin })
|
||||
});
|
||||
if (!pinResp.ok) {
|
||||
const pinResult = await pinResp.json().catch(() => ({}));
|
||||
// resta aperto in modifica sull'utente appena creato, cosi' non si ricrea
|
||||
this.isEditing = true;
|
||||
this.editingUserId = result.id;
|
||||
this.errorMsg = {{ _('Utente creato, ma PIN non impostato')|tojson }} + ': ' + (pinResult.detail || '');
|
||||
return;
|
||||
}
|
||||
result.has_pin = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// #51: reset password dall'admin, usa PUT /admin/api/users/<id>/password
|
||||
openReset(user) {
|
||||
this.resetUser = user;
|
||||
this.resetPw = '';
|
||||
this.resetDone = false;
|
||||
this.resetError = '';
|
||||
this.showReset = true;
|
||||
},
|
||||
|
||||
closeReset() {
|
||||
this.showReset = false;
|
||||
this.resetPw = ''; // la password generata non resta in memoria
|
||||
this.resetDone = false;
|
||||
},
|
||||
|
||||
generateResetPw() {
|
||||
// 12 caratteri senza simboli ambigui (0/O, 1/l/I), da crypto random
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||
const buf = new Uint32Array(12);
|
||||
crypto.getRandomValues(buf);
|
||||
this.resetPw = Array.from(buf, n => chars[n % chars.length]).join('');
|
||||
this.resetError = '';
|
||||
},
|
||||
|
||||
async confirmReset() {
|
||||
if (!this.resetPw || this.resetPw.length < 8) {
|
||||
this.resetError = {{ _('La password deve avere almeno 8 caratteri')|tojson }};
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.resetError = '';
|
||||
try {
|
||||
const resp = await fetch(`/admin/api/users/${this.resetUser.id}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ password: this.resetPw })
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.resetError = result.detail || {{ _('Errore nel cambio password')|tojson }};
|
||||
return;
|
||||
}
|
||||
this.resetDone = true;
|
||||
} catch (e) {
|
||||
this.resetError = {{ _('Errore di connessione al server')|tojson }};
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// #59: imposta / reset / togli PIN, usa PUT /admin/api/users/<id>/pin
|
||||
openPin(user) {
|
||||
this.pinUser = user;
|
||||
this.pinValue = '';
|
||||
this.pinDone = false;
|
||||
this.pinRemoved = false;
|
||||
this.pinError = '';
|
||||
this.showPin = true;
|
||||
},
|
||||
|
||||
closePin() {
|
||||
this.showPin = false;
|
||||
this.pinValue = ''; // il PIN generato non resta in memoria
|
||||
this.pinDone = false;
|
||||
},
|
||||
|
||||
generatePin() {
|
||||
// 4 cifre da crypto random
|
||||
const buf = new Uint32Array(4);
|
||||
crypto.getRandomValues(buf);
|
||||
this.pinValue = Array.from(buf, n => n % 10).join('');
|
||||
this.pinError = '';
|
||||
},
|
||||
|
||||
async savePin(pin) {
|
||||
// pin = stringa di 4 cifre oppure null (toglie il PIN)
|
||||
this.saving = true;
|
||||
this.pinError = '';
|
||||
try {
|
||||
const resp = await fetch(`/admin/api/users/${this.pinUser.id}/pin`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ pin: pin })
|
||||
});
|
||||
const result = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
this.pinError = result.detail || {{ _('Errore nel salvataggio del PIN')|tojson }};
|
||||
return;
|
||||
}
|
||||
// aggiorna l'indicatore locale
|
||||
const idx = this.users.findIndex(u => u.id === this.pinUser.id);
|
||||
if (idx >= 0) this.users[idx] = Object.assign({}, this.users[idx], { has_pin: pin !== null });
|
||||
this.pinUser = idx >= 0 ? this.users[idx] : this.pinUser;
|
||||
this.pinRemoved = pin === null;
|
||||
this.pinDone = true;
|
||||
} catch (e) {
|
||||
this.pinError = {{ _('Errore di connessione al server')|tojson }};
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async confirmPin() {
|
||||
if (!/^\d{4}$/.test(this.pinValue)) {
|
||||
this.pinError = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
|
||||
return;
|
||||
}
|
||||
await this.savePin(this.pinValue);
|
||||
},
|
||||
|
||||
async removePin() {
|
||||
await this.savePin(null);
|
||||
},
|
||||
|
||||
toggleActive(user) {
|
||||
this.toggleUser = user;
|
||||
this.showToggleConfirm = true;
|
||||
},
|
||||
|
||||
async confirmToggle() {
|
||||
if (!this.toggleUser) return;
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const newActive = !this.toggleUser.active;
|
||||
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ active: newActive })
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
alert(result.detail || 'Errore');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
this.showToggleConfirm = false;
|
||||
this.toggleUser = null;
|
||||
} catch (e) {
|
||||
alert('{{ _("Errore di connessione al server") }}');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user