Compare commits
226 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f504c86491 | |||
| 8eb80e20a8 | |||
| b2f82fe779 | |||
| 3c5c5f9bc5 | |||
| a5e8472e88 | |||
| d6cee498ad | |||
| e7d5489438 | |||
| 828471d27a | |||
| cf64a247be | |||
| 813b66d0c5 | |||
| 8c6d6c1630 | |||
| 1afaaa0bae | |||
| cb66371a5b | |||
| 390d6210d0 | |||
| 2760623b53 | |||
| 13eeec7119 | |||
| 0629f1b5ec | |||
| 9eb5942459 | |||
| 11c7d2e98b | |||
| 88bba213e2 | |||
| 8ce7b489d6 | |||
| bc90d327f1 | |||
| e46965cb51 | |||
| c554fcfda7 | |||
| 08e46e335b | |||
| e38e1a2eaf | |||
| 7fa5bacdfe | |||
| 896a30b376 | |||
| 13dbc8f13b | |||
| 8925968f34 | |||
| 2959b79fa2 | |||
| 990b5d0db8 | |||
| 1bfcfaa5f2 | |||
| ad6ce5d11d | |||
| 351739ec36 | |||
| 9f2d624b4f | |||
| 9fe82d7cef | |||
| 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 | |||
| e640c4a36e | |||
| accf416cc9 | |||
| 84a4b5f34d | |||
| 9238f8926b | |||
| a829bf3eb0 | |||
| 7ac291b8ac | |||
| 486e1288bf | |||
| 0d004c43ff | |||
| 35f46926a9 | |||
| 1d93eded27 | |||
| 70e2072b79 | |||
| 97c49f2b5b | |||
| d483fa1b6a | |||
| 48c47e871e | |||
| cc686129c0 | |||
| b27d56d4f7 | |||
| 96dd8d6ea3 | |||
| 2b00f83a2e | |||
| e10585a071 | |||
| 8b43e7a601 | |||
| 2de226f1fd | |||
| ddb764d639 | |||
| c37533110b | |||
| 12d5b7e154 | |||
| 2b59c3ec49 | |||
| 72f0cb1f1b | |||
| c0583da478 | |||
| e8d21b78a9 | |||
| 4384c4860b | |||
| 1bb00b14f2 | |||
| e20299548f | |||
| 805f41c757 | |||
| bfe66a5710 | |||
| 77d95fdcc4 | |||
| 7b015078e9 | |||
| befdce5e6a | |||
| f739f963c7 | |||
| 31fa78bdfd | |||
| ac11cec276 | |||
| 3e99b64fc4 | |||
| 8864b2021b | |||
| 54bc384650 | |||
| 403c923188 | |||
| 65f6a4b18b | |||
| 5ca3dc14cd | |||
| a2f357b3d3 | |||
| 0ec5988581 | |||
| 1c5292d1e0 | |||
| c8caf40af9 | |||
| d40c74870e | |||
| 1576fa190b | |||
| ac76c0a395 | |||
| 302903a8a7 | |||
| b15037851b | |||
| b024724c7e | |||
| 1d5f24510c | |||
| 3f70f99a40 | |||
| 78970c69b0 | |||
| 9afe1e712b | |||
| 92f130479a | |||
| e8a8aeed66 | |||
| 20957e517d | |||
| 10261b66f0 | |||
| 7a21ec4e44 | |||
| e91ef739af | |||
| 1921bc3302 | |||
| 67392a946c | |||
| a330f2b340 | |||
| 1fee29358f | |||
| ac9bc14f5d | |||
| 0dce2299d0 | |||
| d1833ba203 | |||
| 925ed196dd | |||
| 48089bcba6 | |||
| a6d555d07a | |||
| ea9da34899 | |||
| 51f3fe32d4 | |||
| 920ab2fb73 | |||
| 7249cb8a8c | |||
| 8cc5e8266a | |||
| 665d25e574 | |||
| 401457d21f | |||
| 4adad9ab9b | |||
| f930fd98f0 | |||
| 39a6f62089 |
@@ -3,6 +3,8 @@
|
||||
# ===========================
|
||||
|
||||
# --- Database ---
|
||||
# DB_PASSWORD e DB_ROOT_PASSWORD (sezione Docker sotto): in produzione (docker-compose.yml)
|
||||
# sono obbligatorie, senza default — `docker compose config` si rifiuta se mancano.
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_NAME=tiemeasureflow
|
||||
@@ -10,12 +12,20 @@ DB_USER=tmflow
|
||||
DB_PASSWORD=change_me_in_production
|
||||
|
||||
# --- Server ---
|
||||
# APP_ENV: "dev" (default) o "test" fanno partire il server senza controlli;
|
||||
# qualunque altro valore (es. "production") fa scattare i rifiuti descritti
|
||||
# sotto per SERVER_SECRET_KEY e DB_PASSWORD ancora ai valori committati.
|
||||
APP_ENV=dev
|
||||
# SERVER_SECRET_KEY: in produzione obbligatoria, senza default, e il server rifiuta
|
||||
# di avviarsi se resta uguale al valore committato qui sotto.
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8000
|
||||
SERVER_SECRET_KEY=change-this-to-a-random-secret-key
|
||||
SERVER_CORS_ORIGINS=http://localhost:5000
|
||||
|
||||
# --- Client ---
|
||||
# CLIENT_SECRET_KEY: in produzione obbligatoria, senza default, e il client rifiuta
|
||||
# di avviarsi se resta uguale al valore committato qui sotto.
|
||||
CLIENT_HOST=0.0.0.0
|
||||
CLIENT_PORT=5000
|
||||
CLIENT_SECRET_KEY=change-this-to-another-random-secret-key
|
||||
@@ -29,6 +39,11 @@ STATION_CODE=ST-DEFAULT
|
||||
# floor the station identity comes from the local install, and measuring against
|
||||
# another station's recipes would silently break traceability.
|
||||
STATION_SWITCH_ENABLED=0
|
||||
# Cookie di sessione "Secure": di default segue FLASK_DEBUG (sicuro salvo
|
||||
# debug). Impostare a false solo per l'installazione on-premise che serve
|
||||
# HTTP in LAN (#38.4) - un cookie Secure su un'origine non-HTTPS viene
|
||||
# scartato dal browser in silenzio e il login non regge mai.
|
||||
# SESSION_COOKIE_SECURE=false
|
||||
|
||||
# --- Vision ---
|
||||
# Internal address of the vision worker. Never exposed outside tmflow-net.
|
||||
@@ -48,12 +63,40 @@ MAX_UPLOAD_SIZE_MB=50
|
||||
|
||||
# --- Setup Page ---
|
||||
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
|
||||
# Una volta creato un admin, /api/setup/{reset-db,seed,manage-user,
|
||||
# change-user-password,init-db} rispondono 403. true per un deploy demo/test
|
||||
# riprovisionato via script: non impostarlo su un'installazione reale.
|
||||
SETUP_ALLOW_AFTER_INIT=false
|
||||
|
||||
# --- AI (OpenRouter) ---
|
||||
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
|
||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare
|
||||
|
||||
# --- Deploy (introdotte dalla revisione del 21/09/2026) ---
|
||||
# Dominio pubblico dell'installazione, usato nelle rule Traefik di
|
||||
# docker-compose.yml (produzione). Obbligatorio, senza default.
|
||||
APP_DOMAIN=
|
||||
# Quanti hop di reverse proxy fidati ci sono davanti al server (Nginx o
|
||||
# Traefik): serve a calcolare l'IP reale del tablet per il rate limiter.
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
# Numero di worker Uvicorn del server. Il Dockerfile ne avvia 4 di default
|
||||
# indipendentemente da questa variabile; impostarla esplicitamente per farla
|
||||
# coincidere se si cambia il numero di worker nell'immagine.
|
||||
WEB_CONCURRENCY=1
|
||||
# Cookie di sessione Flask con flag Secure. true di default (richiede HTTPS,
|
||||
# come dietro Traefik in docker-compose.yml). Le installazioni on-premise su
|
||||
# rete LAN in HTTP semplice (docker-compose.dev.yml, Nginx senza SSL) la
|
||||
# impostano a false, altrimenti il browser non rimanda mai indietro il cookie.
|
||||
SESSION_COOKIE_SECURE=true
|
||||
# Timeout (secondi) di un'esecuzione del worker di visione.
|
||||
VISION_RUN_TIMEOUT_S=100
|
||||
# Quante esecuzioni di visione in parallelo accetta il worker.
|
||||
VISION_MAX_CONCURRENCY=2
|
||||
# #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.
|
||||
DB_ROOT_PASSWORD=root_password_change_me
|
||||
NGINX_PORT=80
|
||||
NGINX_SSL_PORT=443
|
||||
|
||||
@@ -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" }
|
||||
+9
-13
@@ -33,19 +33,12 @@ env/
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Uploads (server-side files, now at project root)
|
||||
uploads/images/*
|
||||
uploads/pdfs/*
|
||||
uploads/logos/*
|
||||
uploads/reports/*
|
||||
uploads/general/
|
||||
# Statistics files emitted when a production is closed: shop-floor evidence, not
|
||||
# source. They live on the mounted volume in production.
|
||||
uploads/statistics/
|
||||
!uploads/images/.gitkeep
|
||||
!uploads/pdfs/.gitkeep
|
||||
!uploads/logos/.gitkeep
|
||||
!uploads/reports/.gitkeep
|
||||
# Uploads (server-side files, mounted as a volume in production): ignora tutto
|
||||
# sotto uploads/, comprese le sottocartelle create a runtime (vision reference
|
||||
# images, cartelle per ricetta/versione) — tieni solo i .gitkeep che mantengono
|
||||
# le cartelle vuote in git.
|
||||
uploads/*
|
||||
!uploads/**/.gitkeep
|
||||
|
||||
# TailwindCSS output
|
||||
src/frontend/flask_app/static/css/tailwind.css
|
||||
@@ -80,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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*
|
||||
@@ -7,9 +7,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale.
|
||||
Monorepo con **backend FastAPI** (porta 8000) e **frontend Flask** (porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
|
||||
|
||||
## Layout del repository (V2.0.0)
|
||||
## Layout del repository (V3.0.0)
|
||||
|
||||
A partire dalla migrazione V2.0.0 (struttura conforme alla spec `python-project-spec-design.md`):
|
||||
A partire dalla migrazione V2.0.0 (struttura conforme alla spec `python-project-spec-design.md`), con l'aggiunta del modulo di visione in V3.0.0 (`src/vision`, `src/vision_worker`, `vendor/visionsuite`):
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
@@ -18,10 +18,13 @@ TieMeasureFlow/
|
||||
├── .python-version # 3.11
|
||||
├── Dockerfile # Backend (uv + uvicorn)
|
||||
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||
├── Dockerfile.vision # Worker di visione (uv, Python 3.13)
|
||||
├── docker-compose.dev.yml # Dev (Nginx)
|
||||
├── docker-compose.yml # Prod (Traefik + SSL)
|
||||
├── nginx/
|
||||
├── uploads/ # Volume montato in /app/uploads
|
||||
├── vendor/
|
||||
│ └── visionsuite/ # Submodule: libreria di visione (vs-core, vs-task, vs-measure, vs-pm2d)
|
||||
├── docs/
|
||||
└── src/
|
||||
├── backend/
|
||||
@@ -38,16 +41,18 @@ TieMeasureFlow/
|
||||
│ ├── migrations/ # Alembic
|
||||
│ ├── templates/ # Setup page
|
||||
│ └── tests/ # pytest
|
||||
└── frontend/
|
||||
└── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
||||
├── app.py # giustificata: tablet UX server-side, USB calipers,
|
||||
├── config.py # workflow operatore con Fabric.js)
|
||||
├── blueprints/
|
||||
├── services/
|
||||
├── templates/
|
||||
├── static/
|
||||
├── translations/
|
||||
└── tests/
|
||||
├── frontend/
|
||||
│ └── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
||||
│ ├── app.py # giustificata: tablet UX server-side, USB calipers,
|
||||
│ ├── config.py # workflow operatore con Fabric.js)
|
||||
│ ├── blueprints/
|
||||
│ ├── services/
|
||||
│ ├── templates/
|
||||
│ ├── static/
|
||||
│ ├── translations/
|
||||
│ └── tests/
|
||||
├── vision/ # Runner che esegue i grafi di VisionSuite (Python 3.13)
|
||||
└── vision_worker/ # Shell FastAPI attorno al runner, container separato
|
||||
```
|
||||
|
||||
Dipendenze gestite con **uv** (no `requirements.txt`):
|
||||
@@ -280,6 +285,25 @@ Il server esegue Alembic migrations automaticamente all'avvio (`CMD` include `al
|
||||
|
||||
Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120s`, cache statica 7d, security headers duplicati a livello proxy.
|
||||
|
||||
## Visione (V3.0.0)
|
||||
|
||||
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
||||
|
||||
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 2e73691.
|
||||
|
||||
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
|
||||
|
||||
```bash
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.dev.yml build vision
|
||||
```
|
||||
|
||||
Test del runner e del worker, in un ambiente Python 3.13 dedicato (con `uv run pytest` di default, su Python 3.11, questi test risultano *skipped*, non falliti):
|
||||
|
||||
```bash
|
||||
uv run --python 3.13 --extra vision --extra vision-worker --extra dev pytest src/vision/tests src/vision_worker/tests
|
||||
```
|
||||
|
||||
## Convenzioni Codice
|
||||
- Python 3.11+, type hints ovunque
|
||||
- async/await per tutte le operazioni DB (server)
|
||||
@@ -311,3 +335,25 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
||||
- Font UI: Inter
|
||||
- Font 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.
|
||||
|
||||
+18
-3
@@ -1,7 +1,7 @@
|
||||
FROM python:3.11-slim AS base
|
||||
FROM python:3.11.16-slim AS base
|
||||
|
||||
# Install uv (fast Python package manager) from official slim image.
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /usr/local/bin/
|
||||
|
||||
# System libs required by WeasyPrint at runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -13,6 +13,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
shared-mime-info \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user the app runs as at runtime. -m creates a home dir: `uv run`
|
||||
# in the CMD below writes its cache under $HOME, and without one it has
|
||||
# nowhere writable to put it.
|
||||
RUN useradd -m -u 10001 app
|
||||
# Independent of $HOME regardless: keeps the cache path explicit and writable
|
||||
# by the app user even if the home directory assumption ever changes.
|
||||
ENV UV_CACHE_DIR=/tmp/uv-cache
|
||||
# `uv run` must use the venv built by `uv sync` below as it is: re-validating
|
||||
# the project would look for vendor/visionsuite (the vision-worker extra), which
|
||||
# this image does not carry (issue 39). `uv sync` itself is not affected by it.
|
||||
ENV UV_NO_SYNC=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy lockfile + project metadata first to maximize Docker layer cache.
|
||||
@@ -27,7 +39,10 @@ RUN uv sync --frozen --no-dev --extra server
|
||||
COPY src/ ./src/
|
||||
|
||||
# Uploads directory (mounted as a volume in production).
|
||||
RUN mkdir -p /app/uploads/images /app/uploads/pdfs /app/uploads/logos /app/uploads/reports
|
||||
RUN mkdir -p /app/uploads/images /app/uploads/pdfs /app/uploads/logos /app/uploads/reports \
|
||||
&& chown -R app:app /app /tmp/uv-cache
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
+43
-20
@@ -1,13 +1,34 @@
|
||||
FROM python:3.11-slim AS base
|
||||
# TailwindCSS built in its own stage, from the official Node image, instead
|
||||
# of `curl | bash` against nodesource inside the final image (point 2, C2).
|
||||
FROM node:20.20.2-bookworm-slim AS tailwind-build
|
||||
|
||||
WORKDIR /app/flask_app
|
||||
COPY src/frontend/flask_app/ ./
|
||||
|
||||
# Versione esatta, non `tailwindcss@3`. Con il vincolo aperto ogni ricostruzione
|
||||
# dell'immagine poteva prendere una minor diversa e produrre un CSS diverso senza
|
||||
# che nessuno l'avesse validato: per un sistema che produce evidenze per audit
|
||||
# ISO 9001 / IATF 16949 è lo stesso problema che il punto 12 risolve per le
|
||||
# librerie del browser. Aggiornarla è una decisione, non un effetto collaterale.
|
||||
RUN npm install tailwindcss@3.4.19 && \
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
|
||||
FROM python:3.11.16-slim AS base
|
||||
|
||||
# uv from the official slim image (fast Python package manager).
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /usr/local/bin/
|
||||
|
||||
# Node.js 20 is needed at build time to compile TailwindCSS.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Non-root user the app runs as at runtime. -m creates a home dir: `uv run`
|
||||
# in the CMD below writes its cache under $HOME, and without one it has
|
||||
# nowhere writable to put it.
|
||||
RUN useradd -m -u 10001 app
|
||||
# Independent of $HOME regardless: keeps the cache path explicit and writable
|
||||
# by the app user even if the home directory assumption ever changes.
|
||||
ENV UV_CACHE_DIR=/tmp/uv-cache
|
||||
# `uv run` must use the venv built by `uv sync` below as it is: re-validating
|
||||
# the project would look for vendor/visionsuite (the vision-worker extra), which
|
||||
# this image does not carry (issue 39). `uv sync` itself is not affected by it.
|
||||
ENV UV_NO_SYNC=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -16,22 +37,24 @@ COPY pyproject.toml uv.lock ./
|
||||
COPY .python-version ./
|
||||
RUN uv sync --frozen --no-dev --extra client
|
||||
|
||||
# Copy the Flask app sources.
|
||||
# Copy the Flask app sources, then overlay the TailwindCSS build produced in
|
||||
# the node stage above — the final image never installs Node.
|
||||
COPY src/frontend/flask_app/ ./flask_app/
|
||||
|
||||
# Build TailwindCSS (one-shot; no watcher in production image).
|
||||
#
|
||||
# Versione esatta, non `tailwindcss@3`. Con il vincolo aperto ogni ricostruzione
|
||||
# dell'immagine poteva prendere una minor diversa e produrre un CSS diverso senza
|
||||
# che nessuno l'avesse validato: per un sistema che produce evidenze per audit
|
||||
# ISO 9001 / IATF 16949 è lo stesso problema che il punto 12 risolve per le
|
||||
# librerie del browser. Aggiornarla è una decisione, non un effetto collaterale.
|
||||
WORKDIR /app/flask_app
|
||||
RUN npm install tailwindcss@3.4.19 && \
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
COPY --from=tailwind-build /app/flask_app/static/css/tailwind.css ./flask_app/static/css/tailwind.css
|
||||
|
||||
# Compile Flask-Babel translation catalogs.
|
||||
RUN uv run --project /app pybabel compile -d translations
|
||||
WORKDIR /app/flask_app
|
||||
RUN uv run --project /app pybabel compile -d translations && \
|
||||
chown -R app:app /app /tmp/uv-cache
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
+14
-2
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.13.15-slim
|
||||
|
||||
# opencv-python (pulled in transitively by vs-core/vs-task/vs-measure/vs-pm2d)
|
||||
# needs libGL and glib at runtime even though it is never imported by name here.
|
||||
@@ -6,7 +6,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgl1 libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /usr/local/bin/uv
|
||||
|
||||
# Non-root user the app runs as at runtime. Same UID as the server's Dockerfile
|
||||
# so both containers agree on ownership of the shared uploads volume (point 13).
|
||||
# -m creates a home dir: `uv run` in the CMD below writes its cache under
|
||||
# $HOME, and without one it has nowhere writable to put it.
|
||||
RUN useradd -m -u 10001 app
|
||||
# Independent of $HOME regardless: keeps the cache path explicit and writable
|
||||
# by the app user even if the home directory assumption ever changes.
|
||||
ENV UV_CACHE_DIR=/tmp/uv-cache
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
@@ -34,6 +43,9 @@ RUN test -n "$VISION_ENGINE_VERSION" || { \
|
||||
}
|
||||
ENV VISION_ENGINE_VERSION=${VISION_ENGINE_VERSION}
|
||||
|
||||
RUN chown -R app:app /app /tmp/uv-cache
|
||||
USER app
|
||||
|
||||
EXPOSE 8100
|
||||
|
||||
# Two workers, not four: each holds the vision stack in memory.
|
||||
|
||||
@@ -127,16 +127,25 @@ reparto.
|
||||
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni, build delle immagini con `uv` e configurazione Nginx in un solo comando.
|
||||
|
||||
```bash
|
||||
# 1. Clona il repository
|
||||
git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
||||
# 1. Clona il repository CON il sottomodulo di VisionSuite (vendor/visionsuite):
|
||||
# senza --recurse-submodules la cartella resta vuota e la build del worker di
|
||||
# visione fallisce. Il sottomodulo è su ssh://git.tielogic.xyz:222: serve una
|
||||
# chiave SSH autorizzata su quel repo, sia sulla macchina di sviluppo sia sulla
|
||||
# VPS che farà la build.
|
||||
git clone --recurse-submodules ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
||||
cd TieMeasureFlow
|
||||
# Chi ha già clonato senza il sottomodulo:
|
||||
# git submodule update --init
|
||||
|
||||
# 2. Configura le variabili d'ambiente
|
||||
cp .env.example .env
|
||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
|
||||
|
||||
# 3. Avvia i servizi (ambiente di sviluppo)
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
# 3. Valorizza VISION_ENGINE_VERSION PRIMA della build: il worker di visione
|
||||
# (Dockerfile.vision) rifiuta di costruirsi senza — vedi "Il worker di
|
||||
# visione" più sotto per il motivo. Poi avvia i servizi (ambiente di sviluppo).
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
# 4. Verifica lo stato dei container
|
||||
docker compose -f docker-compose.dev.yml ps
|
||||
@@ -324,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 |
|
||||
@@ -460,15 +470,15 @@ variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando
|
||||
mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml`
|
||||
in produzione).
|
||||
|
||||
Stato corrente su `V3.0.0`, con `uv run pytest` su Python 3.11 e senza
|
||||
l'extra `vision` (un conteggio senza il suo ambiente non dice nulla, regola
|
||||
che vale anche qui): **390 pass, 1 fail, 4 skip** — 243 in `src/backend/tests/`,
|
||||
147 pass + 2 skip in `src/frontend/flask_app/tests/`, più 2 skip in
|
||||
Stato corrente su `V3.0.0`, con `uv run --extra server --extra client --extra
|
||||
dev pytest` su Python 3.11 e senza l'extra `vision` (un conteggio senza il
|
||||
suo ambiente non dice nulla, regola che vale anche qui): **622 pass, 4
|
||||
skip, nessun fallimento** — 410 in `src/backend/tests/`, 212 pass + 2 skip
|
||||
in `src/frontend/flask_app/tests/` (geometria propria di
|
||||
`test_layout_shell.py`, non legati alla visione), più 2 skip in
|
||||
`src/vision/tests/` e `src/vision_worker/tests/` per il vincolo su Python
|
||||
3.13 descritto sopra. Il fallimento è preesistente e indipendente da questo
|
||||
lavoro: `test_offline.py::test_no_first_party_script_calls_out`, dovuto a una
|
||||
copia locale di Fabric.js non tracciata da git (`static/js/fabric-debug.js`)
|
||||
che nei propri commenti cita pagine di documentazione esterne.
|
||||
3.13 descritto sopra. Eseguendo questi ultimi nel loro ambiente dedicato
|
||||
(comando sotto) i test collegati alla visione passano tutti: **16 pass**.
|
||||
|
||||
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
|
||||
proprietà che sopravvivono solo se qualcuno le controlla:
|
||||
@@ -506,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).
|
||||
|
||||
+34
-3
@@ -14,6 +14,7 @@ services:
|
||||
--authentication-policy=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--default-time-zone=+00:00
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
@@ -31,14 +32,23 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
APP_ENV: dev
|
||||
DB_HOST: mysql
|
||||
UPLOAD_DIR: uploads
|
||||
VISION_WORKER_URL: http://vision:8100
|
||||
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
|
||||
TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1}
|
||||
volumes:
|
||||
- upload_data:/app/uploads
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
@@ -50,6 +60,8 @@ services:
|
||||
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||
container_name: tmflow-vision
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- upload_data:/app/uploads:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
|
||||
interval: 30s
|
||||
@@ -63,14 +75,31 @@ 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:
|
||||
- .env
|
||||
environment:
|
||||
API_SERVER_URL: http://server:8000
|
||||
# Nginx qui non termina SSL (vedi nginx/nginx.conf): un'installazione
|
||||
# on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure,
|
||||
# altrimenti il browser non lo rimanda mai indietro.
|
||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
||||
# #72: da container si raggiunge il servizio camera (vs-gui) sull'host Linux
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
- server
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/auth/login', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
@@ -87,8 +116,10 @@ services:
|
||||
# - ./certbot/conf:/etc/letsencrypt:ro
|
||||
# - ./certbot/www:/var/www/certbot:ro
|
||||
depends_on:
|
||||
- server
|
||||
- client
|
||||
server:
|
||||
condition: service_healthy
|
||||
client:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
|
||||
+37
-8
@@ -4,16 +4,17 @@ services:
|
||||
container_name: tmflow-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root_password_change_me}
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?manca DB_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME:-tiemeasureflow}
|
||||
MYSQL_USER: ${DB_USER:-tmflow}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-change_me_in_production}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:?manca DB_PASSWORD}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
command: >
|
||||
--authentication-policy=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--default-time-zone=+00:00
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
@@ -31,22 +32,33 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
APP_ENV: production
|
||||
DB_HOST: mysql
|
||||
UPLOAD_DIR: uploads
|
||||
VISION_WORKER_URL: http://vision:8100
|
||||
SERVER_SECRET_KEY: ${SERVER_SECRET_KEY:?manca SERVER_SECRET_KEY}
|
||||
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
|
||||
TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1}
|
||||
volumes:
|
||||
- upload_data:/app/uploads
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.tmflow-api.rule=Host(`tieflow.tielogic.xyz`) && (PathPrefix(`/api/`) || PathPrefix(`/uploads/`))"
|
||||
- "traefik.http.routers.tmflow-api.rule=Host(`${APP_DOMAIN:?manca APP_DOMAIN}`) && PathPrefix(`/api/`)"
|
||||
- "traefik.http.routers.tmflow-api.entrypoints=web,websecure"
|
||||
- "traefik.http.routers.tmflow-api.tls=true"
|
||||
- "traefik.http.routers.tmflow-api.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.tmflow-api.priority=100"
|
||||
- "traefik.http.services.tmflow-api.loadbalancer.server.port=8000"
|
||||
- "traefik.http.routers.tmflow-api.middlewares=tmflow-headers"
|
||||
networks:
|
||||
- tmflow-net
|
||||
- traefik-net
|
||||
@@ -59,6 +71,11 @@ 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:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
|
||||
interval: 30s
|
||||
@@ -72,29 +89,41 @@ 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:
|
||||
- .env
|
||||
environment:
|
||||
API_SERVER_URL: http://server:8000
|
||||
CLIENT_SECRET_KEY: ${CLIENT_SECRET_KEY:?manca CLIENT_SECRET_KEY}
|
||||
depends_on:
|
||||
- server
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/auth/login', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.tmflow-web.rule=Host(`tieflow.tielogic.xyz`)"
|
||||
- "traefik.http.routers.tmflow-web.rule=Host(`${APP_DOMAIN:?manca APP_DOMAIN}`)"
|
||||
- "traefik.http.routers.tmflow-web.entrypoints=web,websecure"
|
||||
- "traefik.http.routers.tmflow-web.tls=true"
|
||||
- "traefik.http.routers.tmflow-web.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.tmflow-web.priority=10"
|
||||
- "traefik.http.services.tmflow-web.loadbalancer.server.port=5000"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.SSLRedirect=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.STSSeconds=315360000"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.browserXSSFilter=true"
|
||||
# Traefik v3: SSLRedirect e browserXSSFilter non ci sono più (SSL redirect
|
||||
# si fa a livello di entrypoint, l'XSS Auditor del browser non esiste più).
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.stsSeconds=315360000"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.contentTypeNosniff=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.forceSTSHeader=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.STSIncludeSubdomains=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.STSPreload=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.customFrameOptionsValue=SAMEORIGIN"
|
||||
- "traefik.http.routers.tmflow-web.middlewares=tmflow-headers"
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
+180
-281
@@ -142,6 +142,19 @@ SSL_KEYFILE=
|
||||
| `SSL_CERTFILE` | string | (empty) | Path to SSL certificate (production) |
|
||||
| `SSL_KEYFILE` | string | (empty) | Path to SSL private key (production) |
|
||||
|
||||
Introdotte dalla revisione del 21/09/2026:
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `APP_ENV` | string | dev | Ambiente di esecuzione. `dev`/`test` saltano il controllo sui segreti di default; qualsiasi altro valore lo attiva. `docker-compose.yml` lo imposta a `production`, `docker-compose.dev.yml` a `dev` |
|
||||
| `APP_DOMAIN` | string | *(nessuno, obbligatoria)* | Dominio pubblico dell'installazione, usato nelle rule Traefik di `docker-compose.yml` (solo produzione) |
|
||||
| `TRUSTED_PROXY_COUNT` | int | 1 | Quanti hop di reverse proxy fidati stanno davanti al server, per calcolare l'IP reale del tablet |
|
||||
| `WEB_CONCURRENCY` | int | 1 | Numero di worker Uvicorn del server; il `Dockerfile` ne avvia 4 di default indipendentemente da questa variabile |
|
||||
| `SESSION_COOKIE_SECURE` | bool | true | Cookie di sessione Flask con flag Secure (richiede HTTPS). `false` per le installazioni on-premise su LAN in HTTP semplice (`docker-compose.dev.yml`) |
|
||||
| `VISION_RUN_TIMEOUT_S` | int | 100 | Timeout, in secondi, di un'esecuzione del worker di visione |
|
||||
| `VISION_MAX_CONCURRENCY` | int | 2 | Esecuzioni di visione in parallelo accettate dal worker |
|
||||
| `VISION_ENGINE_VERSION` | string | *(nessuno, obbligatoria alla build)* | Commit di `vendor/visionsuite` stampato nell'immagine `vision` al momento della build — vedi [Docker Deployment](#docker-deployment) |
|
||||
|
||||
---
|
||||
|
||||
## Database Setup
|
||||
@@ -169,63 +182,33 @@ EXIT;
|
||||
|
||||
### 2. Run Database Migrations
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
This creates all required tables:
|
||||
- `users` - System users
|
||||
- `recipes` - Measurement recipes
|
||||
- `recipe_versions` - Immutable recipe versions
|
||||
- `recipe_tasks` - Tasks within recipes
|
||||
- `recipe_subtasks` - Subtasks within tasks (individual measurements)
|
||||
- `measurements` - Recorded measurements
|
||||
- `access_logs` - API access audit trail
|
||||
- `system_settings` - Configuration key-value pairs
|
||||
- `recipe_version_audit` - Recipe change history
|
||||
|
||||
### 3. Create Initial Admin User (Optional)
|
||||
|
||||
Use the Flask client to create the first admin user, or run:
|
||||
The monorepo is gestito con `uv`, non con `requirements.txt` per sottocartella: si esegue dalla radice del repository, con il flag `-c` perché `alembic.ini` vive in `src/backend/migrations/`.
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -c "
|
||||
from database import init_db, SessionLocal
|
||||
from services.auth_service import create_user
|
||||
import asyncio
|
||||
|
||||
async def init():
|
||||
await init_db()
|
||||
async with SessionLocal() as db:
|
||||
await create_user(
|
||||
db,
|
||||
username='admin',
|
||||
password='change_me_first_login',
|
||||
display_name='Admin',
|
||||
email='admin@example.com',
|
||||
roles=['Maker', 'MeasurementTec', 'Metrologist'],
|
||||
is_admin=True
|
||||
)
|
||||
await db.commit()
|
||||
print('Admin user created: admin / change_me_first_login')
|
||||
|
||||
asyncio.run(init())
|
||||
"
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
```
|
||||
|
||||
Questo è lo stesso comando che il container del server esegue da solo a ogni avvio (`CMD` del `Dockerfile`, prima di `uvicorn`): un deployment Docker non richiede questo passo a mano. La prima migrazione crea lo schema base anche su un database completamente vuoto — non serve più creare le tabelle a parte prima di lanciare `upgrade head`.
|
||||
|
||||
### 3. Create Initial Admin User
|
||||
|
||||
Non c'è più uno script Python da lanciare a mano: il primo utente amministratore si crea dalla pagina `/api/setup` (protetta da `SETUP_PASSWORD`, vedi sopra), che inizializza il database, crea l'admin con le credenziali fornite nel form e, opzionalmente, carica i dati demo — vedi [`README.md`](../README.md#setup-iniziale).
|
||||
|
||||
Una volta creato il primo amministratore, l'endpoint di setup si disattiva da solo: le richieste successive di inizializzazione o creazione utenti ricevono `403 Forbidden`, anche con `SETUP_PASSWORD` corretta. Per rieseguirlo — ad esempio per riconfigurare una stazione da zero — si imposta `SETUP_ALLOW_AFTER_INIT=true` nell'ambiente del server, si esegue l'operazione, e si toglie la variabile: non è pensata per restare attiva in modo permanente.
|
||||
|
||||
---
|
||||
|
||||
## Server Deployment
|
||||
|
||||
Docker Compose (sezione successiva) è il metodo raccomandato e quello che gira
|
||||
in produzione. Questa sezione descrive l'avvio **senza Docker**, utile per
|
||||
sviluppare sul runner senza ricostruire un'immagine a ogni modifica.
|
||||
|
||||
### Development Server
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
uv sync --extra server --extra dev
|
||||
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Runs at `http://0.0.0.0:8000`
|
||||
@@ -234,40 +217,15 @@ API Documentation:
|
||||
- Swagger UI: `http://localhost:8000/docs`
|
||||
- ReDoc: `http://localhost:8000/redoc`
|
||||
|
||||
### Production Server (Gunicorn + Uvicorn)
|
||||
### Production, senza Docker
|
||||
|
||||
Install Gunicorn:
|
||||
Lo stesso comando che gira dentro il container (`Dockerfile`), lanciato dalla
|
||||
radice del repository con l'interprete e il venv pinnati da `uv`:
|
||||
|
||||
```bash
|
||||
pip install gunicorn
|
||||
```
|
||||
|
||||
Start server:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
gunicorn main:app \
|
||||
--workers 4 \
|
||||
--worker-class uvicorn.workers.UvicornWorker \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--access-logfile /var/log/tiemeasureflow/access.log \
|
||||
--error-logfile /var/log/tiemeasureflow/error.log \
|
||||
--log-level info
|
||||
```
|
||||
|
||||
### Production Server (Waitress - Windows)
|
||||
|
||||
Install Waitress:
|
||||
|
||||
```bash
|
||||
pip install waitress
|
||||
```
|
||||
|
||||
Start server:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
waitress-serve --host=0.0.0.0 --port=8000 main:app
|
||||
uv run uvicorn src.backend.main:app \
|
||||
--host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY:-4} \
|
||||
--proxy-headers --forwarded-allow-ips='*'
|
||||
```
|
||||
|
||||
### systemd Service (Linux)
|
||||
@@ -280,14 +238,14 @@ Description=TieMeasureFlow API Server
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
Type=simple
|
||||
User=tiemeasureflow
|
||||
WorkingDirectory=/opt/tiemeasureflow/server
|
||||
Environment="PATH=/opt/tiemeasureflow/venv/bin"
|
||||
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn main:app \
|
||||
--workers 4 \
|
||||
--worker-class uvicorn.workers.UvicornWorker \
|
||||
--bind 0.0.0.0:8000
|
||||
WorkingDirectory=/opt/tiemeasureflow
|
||||
EnvironmentFile=/opt/tiemeasureflow/.env
|
||||
Environment="WEB_CONCURRENCY=4"
|
||||
ExecStart=/opt/tiemeasureflow/.venv/bin/uv run uvicorn src.backend.main:app \
|
||||
--host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY} \
|
||||
--proxy-headers --forwarded-allow-ips='*'
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
@@ -315,12 +273,14 @@ sudo journalctl -u tiemeasureflow-api -f
|
||||
|
||||
## Client Deployment
|
||||
|
||||
Anche qui, Docker Compose è il metodo raccomandato; questa sezione è per lo
|
||||
sviluppo o un'installazione manuale senza container.
|
||||
|
||||
### Development Server
|
||||
|
||||
```bash
|
||||
cd client
|
||||
pip install -r requirements.txt
|
||||
flask run --host 0.0.0.0 --port 5000
|
||||
uv sync --extra client --extra dev
|
||||
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
|
||||
```
|
||||
|
||||
Runs at `http://0.0.0.0:5000`
|
||||
@@ -328,16 +288,20 @@ Runs at `http://0.0.0.0:5000`
|
||||
### Compile Tailwind CSS (Optional)
|
||||
|
||||
```bash
|
||||
cd client
|
||||
cd src/frontend/flask_app
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
```
|
||||
|
||||
### Production Server (Gunicorn)
|
||||
### Production, senza Docker
|
||||
|
||||
Lo stesso comando del `Dockerfile.frontend` (gunicorn richiede la cwd dentro
|
||||
`flask_app` per risolvere `app:create_app()`):
|
||||
|
||||
```bash
|
||||
pip install gunicorn
|
||||
cd client
|
||||
gunicorn --workers 4 --bind 0.0.0.0:5000 app:app
|
||||
cd src/frontend/flask_app && uv run --project ../../.. gunicorn \
|
||||
--workers 5 --threads 4 --worker-class gthread --timeout 60 \
|
||||
--bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
|
||||
"app:create_app()"
|
||||
```
|
||||
|
||||
### systemd Service (Linux)
|
||||
@@ -350,15 +314,14 @@ Description=TieMeasureFlow Web Client
|
||||
After=network.target tiemeasureflow-api.service
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
Type=simple
|
||||
User=tiemeasureflow
|
||||
WorkingDirectory=/opt/tiemeasureflow/client
|
||||
Environment="PATH=/opt/tiemeasureflow/venv/bin"
|
||||
Environment="FLASK_ENV=production"
|
||||
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn \
|
||||
--workers 4 \
|
||||
--bind 0.0.0.0:5000 \
|
||||
app:app
|
||||
WorkingDirectory=/opt/tiemeasureflow/src/frontend/flask_app
|
||||
EnvironmentFile=/opt/tiemeasureflow/.env
|
||||
ExecStart=/opt/tiemeasureflow/.venv/bin/uv run --project /opt/tiemeasureflow gunicorn \
|
||||
--workers 5 --threads 4 --worker-class gthread --timeout 60 \
|
||||
--bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
|
||||
"app:create_app()"
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
@@ -373,8 +336,9 @@ WantedBy=multi-user.target
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- [ ] Change all default passwords in `.env`
|
||||
- [ ] Change all default passwords in `.env` — con `docker-compose.yml`, `DB_ROOT_PASSWORD`, `DB_PASSWORD`, `SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY` non hanno più un default: `docker compose config` si rifiuta di partire se mancano (introdotto dalla revisione del 21/09/2026)
|
||||
- [ ] Generate random `SERVER_SECRET_KEY` (32+ characters)
|
||||
- [ ] Set `APP_DOMAIN` to the real public domain (used by the Traefik router rules)
|
||||
- [ ] Set CORS origins to actual client domains
|
||||
- [ ] Enable SSL/HTTPS (see SSL/TLS section)
|
||||
- [ ] Configure firewall rules
|
||||
@@ -386,200 +350,140 @@ WantedBy=multi-user.target
|
||||
|
||||
### Recommended Architecture
|
||||
|
||||
Il progetto usa due reverse proxy diversi a seconda dell'installazione, non uno
|
||||
generico da configurare a mano:
|
||||
|
||||
- **On-premise, rete LAN, HTTP semplice** (`docker-compose.dev.yml`): Nginx,
|
||||
configurazione già pronta in [`nginx/nginx.conf`](../nginx/nginx.conf). È il
|
||||
caso di una stazione di misura sul reparto, senza certificato TLS.
|
||||
- **Produzione, dominio pubblico** (`docker-compose.yml`): Traefik, con
|
||||
certificati Let's Encrypt automatici. La configurazione sta nelle label
|
||||
Docker del servizio, non in un file a parte — vedi la sezione
|
||||
[Docker Deployment](#docker-deployment) sotto.
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Nginx Proxy │ (Port 80/443)
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
┌───▼──┐ ┌───▼──┐
|
||||
│API │ │Web │
|
||||
│:8000 │ │:5000 │
|
||||
└──────┘ └──────┘
|
||||
│ │
|
||||
└────┬─────┘
|
||||
│
|
||||
┌────▼────┐
|
||||
│ MySQL │
|
||||
│ :3306 │
|
||||
└─────────┘
|
||||
┌──────────────────────────┐
|
||||
On-premise (LAN) │ Nginx (:80) │
|
||||
└─────────────┬────────────┘
|
||||
│
|
||||
Produzione (dominio pubblico) → Traefik (:80/:443, TLS Let's Encrypt)
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
┌─────▼─────┐ ┌──────▼─────┐
|
||||
│ server │ │ client │
|
||||
│ :8000 │ │ :5000 │
|
||||
└─────┬──────┘ └────────────┘
|
||||
│
|
||||
┌─────▼──────┐ ┌─────────────┐
|
||||
│ vision │ │ MySQL 8.0 │
|
||||
│ :8100 │ │ :3306 │
|
||||
│ (interno) │ └─────────────┘
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### Nginx Configuration
|
||||
|
||||
Create `/etc/nginx/sites-available/tiemeasureflow`:
|
||||
|
||||
```nginx
|
||||
upstream tiemeasureflow_api {
|
||||
server 127.0.0.1:8000;
|
||||
}
|
||||
|
||||
upstream tiemeasureflow_web {
|
||||
server 127.0.0.1:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name yourdomain.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/yourdomain.com.crt;
|
||||
ssl_certificate_key /etc/ssl/private/yourdomain.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
# API
|
||||
location /api/ {
|
||||
proxy_pass http://tiemeasureflow_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Web Client
|
||||
location / {
|
||||
proxy_pass http://tiemeasureflow_web;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Static files (optional caching)
|
||||
location /static/ {
|
||||
proxy_pass http://tiemeasureflow_web;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable and test:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/tiemeasureflow /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
Il worker di visione (`vision`) non pubblica porte verso l'esterno: lo
|
||||
raggiunge solo il `server`, sulla rete Docker interna `tmflow-net`.
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Docker Compose (Complete Stack)
|
||||
Docker Compose è il metodo raccomandato ed è quello usato in produzione. Il
|
||||
progetto ha **tre Dockerfile alla radice** del repository — non uno a
|
||||
sottocartella:
|
||||
|
||||
The project includes a `docker-compose.yml` for easy deployment:
|
||||
| File | Servizio | Base |
|
||||
|---|---|---|
|
||||
| `Dockerfile` | `server` (FastAPI) | `python:3.11.16-slim` |
|
||||
| `Dockerfile.frontend` | `client` (Flask) | `python:3.11.16-slim`, TailwindCSS compilato in uno stage `node:20.20.2-bookworm-slim` |
|
||||
| `Dockerfile.vision` | `vision` (worker di visione) | `python:3.13.15-slim` |
|
||||
|
||||
E due file compose:
|
||||
|
||||
- **`docker-compose.dev.yml`** — sviluppo/on-premise, con Nginx (vedi
|
||||
[Recommended Architecture](#recommended-architecture) sopra). 4 servizi:
|
||||
`mysql`, `server`, `vision`, `client`, `nginx`.
|
||||
- **`docker-compose.yml`** — produzione, con Traefik e TLS automatico. 4
|
||||
servizi: `mysql`, `server`, `vision`, `client`. Richiede la rete Docker
|
||||
esterna `traefik` (vedi `networks.traefik-net` nel file) e le variabili
|
||||
`APP_DOMAIN`, `DB_ROOT_PASSWORD`, `DB_PASSWORD`, `SERVER_SECRET_KEY`,
|
||||
`CLIENT_SECRET_KEY` valorizzate — senza default, `docker compose config` si
|
||||
rifiuta di procedere se mancano.
|
||||
|
||||
Il worker di visione dipende dal submodule `vendor/visionsuite`
|
||||
(`git clone --recurse-submodules`, vedi il [Quick Start del README](../README.md#quick-start-con-docker))
|
||||
e richiede `VISION_ENGINE_VERSION` valorizzata **prima** della build, altrimenti
|
||||
`Dockerfile.vision` fallisce di proposito:
|
||||
|
||||
```bash
|
||||
# Build images
|
||||
docker-compose build
|
||||
# Sviluppo / on-premise
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
# Produzione
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
### Manual Docker Build
|
||||
|
||||
#### API Server
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app/server
|
||||
|
||||
COPY server/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY server/ .
|
||||
COPY .env ..
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
Build and run:
|
||||
Comandi comuni (sintassi `docker compose` v2, senza trattino — il plugin, non
|
||||
il vecchio `docker-compose` standalone):
|
||||
|
||||
```bash
|
||||
docker build -f server/Dockerfile -t tiemeasureflow-api .
|
||||
docker run -p 8000:8000 --env-file .env tiemeasureflow-api
|
||||
docker compose -f docker-compose.yml ps
|
||||
docker compose -f docker-compose.yml logs -f server
|
||||
docker compose -f docker-compose.yml down
|
||||
```
|
||||
|
||||
#### Web Client
|
||||
### Aggiornare un'installazione esistente
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
Le immagini precedenti creavano l'utente non privilegiato senza una home
|
||||
directory (`useradd -u 10001 app`), e il volume `upload_data` che ne è
|
||||
risultato appartiene a `root`. Le immagini attuali creano l'utente con
|
||||
`-m` e impostano `UV_CACHE_DIR=/tmp/uv-cache`, ma questo non cambia da solo
|
||||
la proprietà di un volume già esistente: se si riparte con le nuove immagini
|
||||
su un volume creato da una versione precedente, il container `server` non
|
||||
riesce a scrivere sotto `/app/uploads`.
|
||||
|
||||
WORKDIR /app/client
|
||||
|
||||
COPY client/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY client/ .
|
||||
COPY .env ..
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:5000", "app:app"]
|
||||
```
|
||||
|
||||
Build and run:
|
||||
Prima del primo avvio con le nuove immagini, va quindi corretta la proprietà
|
||||
del volume:
|
||||
|
||||
```bash
|
||||
docker build -f client/Dockerfile -t tiemeasureflow-web .
|
||||
docker run -p 5000:5000 --env-file .env tiemeasureflow-web
|
||||
docker compose -f docker-compose.yml run --rm --user root --entrypoint chown server -R 10001:10001 /app/uploads
|
||||
```
|
||||
|
||||
(sostituire `docker-compose.yml` con `docker-compose.dev.yml` per
|
||||
un'installazione di sviluppo/on-premise). Il comando usa il servizio
|
||||
`server` perché è quello che monta il volume `upload_data` in lettura e
|
||||
scrittura; il worker di visione lo monta in sola lettura e non ne ha
|
||||
bisogno. Un'installazione nuova, che parte da un volume vuoto, non richiede
|
||||
questo passo.
|
||||
|
||||
---
|
||||
|
||||
## SSL/TLS
|
||||
|
||||
### Self-Signed Certificate (Development)
|
||||
Le due installazioni gestiscono il TLS in due modi diversi, e nessuno dei due
|
||||
richiede un certificato generato a mano:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes
|
||||
```
|
||||
- **Produzione** (`docker-compose.yml`): Traefik ottiene e rinnova da solo un
|
||||
certificato Let's Encrypt per `APP_DOMAIN`, tramite il `certresolver`
|
||||
configurato nelle label del servizio (`tls.certresolver=mytlschallenge`).
|
||||
Serve solo che `APP_DOMAIN` punti davvero al server (DNS) e che le porte
|
||||
80/443 siano raggiungibili da internet per la sfida ACME.
|
||||
- **On-premise, rete LAN** (`docker-compose.dev.yml`): Nginx serve in HTTP
|
||||
semplice, senza TLS — è il caso descritto in
|
||||
[Recommended Architecture](#recommended-architecture). Chi ha comunque
|
||||
bisogno di un certificato (es. una CA interna) lo aggiunge scommentando la
|
||||
sezione SSL già presente, ma commentata, in
|
||||
[`nginx/nginx.conf`](../nginx/nginx.conf).
|
||||
|
||||
### Let's Encrypt (Production)
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot certonly --nginx -d yourdomain.com
|
||||
```
|
||||
|
||||
### Configure in .env
|
||||
|
||||
```env
|
||||
SSL_CERTFILE=/etc/ssl/certs/yourdomain.com.crt
|
||||
SSL_KEYFILE=/etc/ssl/private/yourdomain.com.key
|
||||
```
|
||||
|
||||
### Start Server with SSL
|
||||
|
||||
```bash
|
||||
cd server
|
||||
gunicorn main:app \
|
||||
--workers 4 \
|
||||
--worker-class uvicorn.workers.UvicornWorker \
|
||||
--bind 0.0.0.0:8443 \
|
||||
--certfile /etc/ssl/certs/yourdomain.com.crt \
|
||||
--keyfile /etc/ssl/private/yourdomain.com.key
|
||||
```
|
||||
Un'installazione manuale (senza Docker, sezioni [Server](#server-deployment) e
|
||||
[Client Deployment](#client-deployment) sopra) che debba terminare il TLS da
|
||||
sola, dietro il proprio reverse proxy, può ancora valorizzare
|
||||
`SSL_CERTFILE`/`SSL_KEYFILE` in `.env` — variabili lasciate per questo caso,
|
||||
non lette da uvicorn/gunicorn nei comandi mostrati sopra: vanno passate al
|
||||
proprio reverse proxy o gestite fuori da questo progetto.
|
||||
|
||||
---
|
||||
|
||||
@@ -644,14 +548,11 @@ sqlalchemy.exc.OperationalError: (asyncmy.errors.ProgrammingError) (2003, "Can't
|
||||
|
||||
**Check:**
|
||||
```bash
|
||||
# Verify MySQL is running
|
||||
# Verify MySQL is running (con Docker: docker compose ps mysql, ha già un healthcheck)
|
||||
mysql -u tmflow -p -e "SELECT 1"
|
||||
|
||||
# Check .env variables
|
||||
# Check .env variables — non stampare mai la password: si controlla il valore, non la connection string
|
||||
grep DB_ .env
|
||||
|
||||
# Test connection string
|
||||
cd server && python -c "from config import settings; print(settings.database_url)"
|
||||
```
|
||||
|
||||
### Port Already in Use
|
||||
@@ -667,7 +568,7 @@ lsof -i :8000
|
||||
kill -9 <PID>
|
||||
|
||||
# Or use different port
|
||||
uvicorn main:app --port 8001
|
||||
uv run uvicorn src.backend.main:app --port 8001
|
||||
```
|
||||
|
||||
### Import Errors
|
||||
@@ -678,21 +579,20 @@ ModuleNotFoundError: No module named 'fastapi'
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
uv sync --extra server --extra client --extra dev
|
||||
```
|
||||
|
||||
### File Upload Not Working
|
||||
|
||||
```
|
||||
/app/server/uploads: Permission denied
|
||||
uploads: Permission denied
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Create uploads directory with correct permissions
|
||||
mkdir -p server/uploads
|
||||
chmod 755 server/uploads
|
||||
# Create uploads directory with correct permissions (radice del repo, non server/uploads)
|
||||
mkdir -p uploads/images uploads/pdfs uploads/logos uploads/reports
|
||||
chmod 755 uploads
|
||||
```
|
||||
|
||||
### Migrations Failed
|
||||
@@ -703,13 +603,12 @@ alembic.util.exc.CommandError: Can't locate revision identified by...
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd server
|
||||
# Check migration status
|
||||
alembic current
|
||||
uv run alembic -c src/backend/migrations/alembic.ini current
|
||||
|
||||
# Reset migrations (CAUTION - deletes data)
|
||||
alembic downgrade base
|
||||
alembic upgrade head
|
||||
uv run alembic -c src/backend/migrations/alembic.ini downgrade base
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
```
|
||||
|
||||
### API Key Not Working
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
# La visione in TieMeasureFlow — stato verificato e specifica del lavoro che manca
|
||||
|
||||
> Progetto: TieMeasureFlow, ramo **`V3.0.0`** (HEAD `39a6f62`, 17/08/2026)
|
||||
> Data: 14 settembre 2026
|
||||
> Stato: specifica da approvare
|
||||
> Sostituisce, come documento operativo: il piano `docs/superpowers/plans/2026-08-16-vision-fondamenta-server.md`
|
||||
> (eseguito) e la corsia `A1-tiemeasureflow.md` del piano ferie (eseguita, cancellata dal disco il 01/09 e
|
||||
> recuperabile con `git show 874a7a4^:docs/plans/corsie-fase2/A1-tiemeasureflow.md` nel repo AI-OS).
|
||||
> **Resta in vigore** `docs/superpowers/specs/2026-08-16-visionsuite-integration-design.md`: le sue otto
|
||||
> decisioni architetturali non vengono riscritte qui, vengono richiamate.
|
||||
|
||||
## 1. Perché questo documento
|
||||
|
||||
Fra il 16 e il 17 agosto sono entrate in TieMeasureFlow le fondamenta della visione: ventiquattro commit,
|
||||
sette file di test nuovi, tre migrazioni. Da allora il lavoro si è fermato. Nel frattempo VisionSuite è
|
||||
andato avanti di sessantasette commit, la scheda di commessa non registra nulla dopo il 25 agosto, e
|
||||
l'installazione a Tràfilo, prevista per settembre, è alle porte insieme alla fiera del 27-29 ottobre.
|
||||
|
||||
Questo documento fa due cose. Prima dichiara, verificandolo sul codice e non sui piani, **che cosa esiste
|
||||
davvero** sul ramo `V3.0.0`. Poi specifica **che cosa manca** per arrivare a ciò che il cliente ha chiesto
|
||||
il 27 luglio: pattern matching, confronto del profilo con il disegno DXF e due o tre quote lineari, dentro
|
||||
l'interfaccia di TieMeasureFlow, con l'esito OK/warning/fail che il prodotto già sa dare.
|
||||
|
||||
La distanza fra le due cose è più grande di quanto i documenti di agosto lascino credere, e la ragione è
|
||||
una sola, spiegata al capitolo 3.
|
||||
|
||||
## 2. Quello che esiste, verificato sul ramo V3.0.0
|
||||
|
||||
Ogni riga di questa tabella è stata controllata aprendo il file indicato nel clone del ramo `V3.0.0` il
|
||||
14/09/2026. Non è lo stato dichiarato da un piano: è lo stato del disco.
|
||||
|
||||
| Cosa | Stato | Dove si vede |
|
||||
|---|---|---|
|
||||
| VisionSuite come sottomodulo git, su commit fisso | ✅ | `.gitmodules`, pin `f095fcc` (15/08) |
|
||||
| Runner unico, senza HTTP né database | ✅ | `src/vision/runner.py`, 129 righe |
|
||||
| Versione del motore timbrata su ogni numero prodotto | ✅ | `runner.engine_version()`, errore se non risolve |
|
||||
| Vision worker come servizio separato nel compose | ✅ | `src/vision_worker/main.py`, `Dockerfile.vision`, servizio `vision` |
|
||||
| Il server non importa mai VisionSuite | ✅ e **sorvegliato** | `src/backend/tests/test_vision_boundary.py` |
|
||||
| Grafo di visione sul task (`vision_json`) | ✅ | migrazione `011`, `models/orm/task.py` |
|
||||
| Legame quota del grafo → quota della ricetta (`vision_output`) | ✅ | migrazione `011`, `recipe_subtasks` |
|
||||
| `camera` fra i metodi di ingresso misura | ✅ | migrazione `011`, enum `input_method` |
|
||||
| Tabella `vision_results`, una riga per acquisizione | ✅ | migrazione `012`, `models/orm/vision.py` |
|
||||
| Immagini di riferimento con provenienza | ✅ | migrazione `013`, `vision_reference_images` |
|
||||
| Il verdetto lo dà TieMeasureFlow, non il grafo | ✅ | `vision_service` chiama `measurement_service` |
|
||||
| Gate del fuori tolleranza valutato per acquisizione | ✅ | `enforce_tolerance_gate=False` più gate a monte |
|
||||
| Copy-on-write delle ricette che copia anche il grafo | ✅ | `test_vision_copy_on_write.py` |
|
||||
| Il refuso `xf_compare` corretto in `dxf_compare` | ✅ | migrazione `011`, prima che una riga lo usasse |
|
||||
| Limiti di tipo e dimensione sugli endpoint di upload | ✅ | `test_vision_upload_limits.py` |
|
||||
| Il guasto del worker resta del worker | ✅ | `vision_service._call_worker`, 502/504 nominati |
|
||||
| L'applicazione parte senza internet | ✅ | `static/vendor/`, `test_offline.py` |
|
||||
| API `POST /api/vision/execute`, `/preview`, `/reference-images` | ✅ | `api/routers/vision.py` |
|
||||
|
||||
Trentanove test di visione fra `src/backend/tests/test_vision_*.py`, `src/vision/tests/` e
|
||||
`src/vision_worker/tests/`. ⚠️ **Non li ho eseguiti**: l'albero della visione richiede Python 3.13 e il
|
||||
sottomodulo inizializzato, cioè un'installazione da alcuni gigabyte che su questa macchina non c'è. Il
|
||||
conteggio è dei file; la loro verdezza è dichiarata dai commit del 16-17 agosto e va riverificata da chi
|
||||
riprende, prima di scriverci sopra.
|
||||
|
||||
**In sintesi: le fondamenta ci sono e sono fatte bene.** Il confine architetturale è difeso da un test
|
||||
invece che da una buona intenzione, la provenienza di ogni numero è tracciata, e il modello dati regge il
|
||||
resto del lavoro senza ristrutturazioni.
|
||||
|
||||
## 3. Il buco strutturale: pattern matching e DXF compare non sono eseguibili dal grafo
|
||||
|
||||
> ✅ **Colmato in VisionSuite il 14/09/2026** (commit `6f5990c`, issue #2). Quello che segue è la
|
||||
> diagnosi com'era, e resta perché spiega perché il ponte sta dove sta. Il seguito, cioè cosa manca
|
||||
> ancora **qui**, è nella fase V1.
|
||||
|
||||
È il punto che i documenti di agosto non dicono, ed è quello che decide il calendario.
|
||||
|
||||
Il runner esegue **un grafo `vs-task`**: `Task.from_dict(graph)`, poi `task.run(context)`. Gli strumenti
|
||||
che un grafo può contenere sono quelli registrati in `vs-task`, e al 14/09 — sia sul commit fissato
|
||||
`f095fcc`, sia sull'ultimo commit di VisionSuite — sono **dieci, tutti geometrici**:
|
||||
|
||||
`DetectLine`, `DetectCircle`, `CircleCenter`, `PointIntersectionLL`, `Distance2P`, `DistancePL`,
|
||||
`DistanceCL`, `Angle2L`, `Radius`, `Diameter`.
|
||||
|
||||
Nessuno strumento di pattern matching. Nessuno strumento di confronto DXF. Una ricerca di `pm2d`,
|
||||
`shape_model` o `dxf` dentro `packages/vs-task/src` non trova nulla.
|
||||
|
||||
Coerentemente, in `pyproject.toml` gli extra `vision` e `vision-worker` installano `visionsuite` (core),
|
||||
`vs-task`, `vs-measure`, `vs-pm2d` e `numpy`: **`vs-dxf` non è installato affatto**, e `vs-pm2d` è
|
||||
installato ma il grafo non ha modo di chiamarlo.
|
||||
|
||||
I due motori esistono, e sono buoni: `visionsuite.pm2d` ha il matcher e l'auto-tune, `visionsuite.dxf` ha
|
||||
`DXFCompare`, l'`ICPAligner`, il ricampionamento, il controllo di tolleranza e il generatore di rapporto.
|
||||
Ma sono **librerie**, non strumenti di grafo. Fra loro e TieMeasureFlow manca un ponte, e il ponte va
|
||||
costruito **in VisionSuite**, non qui: la decisione 4 della spec di agosto — il server non importa mai
|
||||
VisionSuite — e il criterio di accettazione della corsia A1 — *«`grep` non trova algoritmi di visione dentro
|
||||
il repo di TieMeasureFlow»* — rendono illegittima qualunque scorciatoia che scriva visione dentro questo
|
||||
repository.
|
||||
|
||||
🔑 **La conseguenza commerciale, detta chiara:** oggi TieMeasureFlow sa eseguire un grafo che misura
|
||||
distanze, angoli, raggi e diametri su un'immagine. Le due cose che Menoncin ha chiesto per nome — il
|
||||
confronto DXF, che il suo cliente finale non ha, e il pattern matching, che il loro «funziona male» — non
|
||||
sono ancora raggiungibili da nessun percorso del prodotto.
|
||||
|
||||
## 4. Le altre lacune, in ordine di peso
|
||||
|
||||
1. **Nessuna interfaccia.** Nel frontend Flask la visione compare in due sole righe di
|
||||
`templates/measure/task_list.html`, le etichette «Confronto profilo» e «Misura camera». Non esiste la
|
||||
schermata del Maker per comporre un grafo, scegliere un'immagine di riferimento, agganciare un'uscita a
|
||||
una quota o premere «prova»; non esiste la schermata dell'operatore che acquisisce e vede l'esito. Oggi
|
||||
un grafo entra solo via `POST /api/tasks` scrivendo il JSON a mano.
|
||||
2. **Nessun agente di stazione, nessun registro dei device.** La tabella `station_devices` della spec non
|
||||
esiste in nessuna migrazione; il WebSocket `/api/stations/agent` non esiste; `vs-camera`, cioè il
|
||||
pacchetto che contiene il driver Itala, non è montato. L'unico modo di far entrare un'immagine è un
|
||||
upload HTTP.
|
||||
3. **L'immagine di produzione non viene conservata.** `vision_results.image_path` e `overlay_path`
|
||||
migrano e restano vuote: nessuna riga le scrive. Una misura contestata non ha prova materiale, e
|
||||
l'overlay — l'immagine con sopra le zone dove il sistema ha guardato — non esiste.
|
||||
4. **La prova di non regressione è promessa e non mantenuta.** `expected_json` esiste sulla tabella delle
|
||||
immagini di riferimento; nessun percorso la scrive, `preview` compreso. È la rete di sicurezza che
|
||||
rende sostenibile aggiornare il sottomodulo con il motore installato su due host: senza, ogni
|
||||
aggiornamento di VisionSuite è un salto al buio.
|
||||
5. **Il sottomodulo è indietro di 67 commit**, dal 15/08 al 10/09. Fra questi c'è almeno una correzione di
|
||||
`vs-core` sulla ricomposizione dei mosaici. Aggiornare il pin non è manutenzione facoltativa: è la
|
||||
decisione 2 della spec, che vuole server e stazione sullo stesso commit, e va fatta con la prova di non
|
||||
regressione già in piedi — cioè dopo il punto 4, non prima.
|
||||
6. **Le tre verifiche a monte non sono mai state fatte**, e la spec di agosto le poneva come condizione
|
||||
d'ingresso: lo stato reale di VisionSuite rispetto al suo README, il test di calibrazione dato per
|
||||
rosso (focale recuperata a 373 contro 456 attesi, tolleranza 5%) e il peso effettivo del sottoinsieme
|
||||
di pacchetti che il worker installa. La seconda pesa più delle altre: **tutte le quote in millimetri
|
||||
poggiano sulla calibrazione**, e finché quel test non è verde o spiegato, i millimetri che il sistema
|
||||
produce non hanno garanzia.
|
||||
|
||||
## 5. Che cosa si vuole ottenere
|
||||
|
||||
Un operatore apre un task di visione su una postazione, appoggia il profilato sul piano, e il sistema:
|
||||
|
||||
- riconosce il pezzo e la sua posa (**pattern matching**);
|
||||
- ne confronta il profilo con il **DXF di riferimento**, dichiarando lo scarto massimo e dove sta;
|
||||
- ne ricava **due o tre quote lineari** (le quote massima e minima che il cliente chiede);
|
||||
- applica le tolleranze della ricetta e dà **OK / warning / fail**, con il gate del fuori tolleranza e
|
||||
l'autorizzazione del capoturno come per qualunque altra misura;
|
||||
- registra tutto in `measurements` e `vision_results`, con immagine, overlay, versione del motore e
|
||||
taratura in vigore.
|
||||
|
||||
Nulla di quanto il prodotto già fa — ricette, SPC, Cp/Cpk, carta di controllo, export, tracciabilità —
|
||||
deve cambiare. Una quota da camera resta una misura come le altre: è la decisione 7, e va tenuta.
|
||||
|
||||
## 6. Il lavoro, in sei fasi
|
||||
|
||||
Le fasi sono ordinate per dipendenza. Le prime due sono il cammino critico verso la fiera; la terza è il
|
||||
cammino critico verso l'installazione a Tràfilo. Non sono stimate in ore: una stima si scrive dopo aver
|
||||
guardato il codice dei due motori, e quella lettura fa parte della fase V0.
|
||||
|
||||
### V0 — Le verifiche a monte, prima di scrivere righe
|
||||
|
||||
Le tre della spec di agosto (capitolo 4, punto 6), più una quarta che nasce da questo documento: leggere
|
||||
`visionsuite.pm2d.matcher` e `visionsuite.dxf.compare` per sapere **che forma hanno gli ingressi e le
|
||||
uscite** dei due motori, perché è quella forma che decide la firma degli strumenti della fase V1.
|
||||
|
||||
*Fatto quando:* il test di calibrazione è verde o il suo scarto è spiegato per iscritto; il peso
|
||||
dell'ambiente del worker è un numero misurato; esiste una nota di due pagine con le firme reali dei due
|
||||
motori.
|
||||
|
||||
### V1 — Il ponte — ✅ **fatta il 14/09/2026** (VisionSuite `6f5990c`, issue #2 chiusa)
|
||||
|
||||
**Il lavoro è stato fatto in VisionSuite**, come questo documento prescriveva. Gli strumenti non sono
|
||||
finiti in `vs-task` insieme ai dieci geometrici, ma **ciascuno nel pacchetto del proprio motore** —
|
||||
`visionsuite.pm2d.task_tools` e `visionsuite.dxf.task_tools` — perché altrimenti il livello-task avrebbe
|
||||
ereditato numba, opencv e fastapi da `pm2d` e scipy ed ezdxf da `dxf`, e se li sarebbe portati dietro
|
||||
ogni consumatore che voleva soltanto una distanza fra due punti. Si dichiarano con l'extra `task`.
|
||||
|
||||
- `MatchPattern` — ingressi: la scena dal contesto, il modello per chiave o per percorso, punteggio
|
||||
minimo, intervallo di angolo e di scala. Uscite: `found`, `score`, `row`, `col`, `angle_deg`, `scale`,
|
||||
`n_matches`.
|
||||
- `CompareDXF` — ingressi: immagine, `dxf_path`, `px_per_mm` (o la calibrazione dal contesto),
|
||||
`tolerance_mm`. Uscite: `max_deviation_mm`, `mean_deviation_mm`, `n_points`, `overlay`.
|
||||
|
||||
**Il vincolo che ha retto:** gli strumenti espongono numeri, non verdetti. `CompareResult.passed` esiste
|
||||
e resta fuori dalle uscite, e un test lo sorveglia. La `tolerance_mm` serve **solo a colorare l'overlay**:
|
||||
è un parametro di disegno, non una soglia di accettazione.
|
||||
|
||||
⚠️ **Due cose che questo documento dava per buone non hanno retto alla prova, ed è il motivo per cui una
|
||||
specifica si verifica invece di eseguirla.**
|
||||
|
||||
1. **«`vs-dxf` si porta dietro PyTorch per una riga» era falso.** Torch è stato tolto in L2 insieme al
|
||||
rilevatore DexiNed che non aveva mai girato, e il `pyproject.toml` di `vs-dxf` lo dice per esteso. La
|
||||
nota veniva dalla scheda VisionSuite in AI-OS, che su quel punto era invecchiata. Il lavoro non
|
||||
esisteva: il container della visione non triplica.
|
||||
2. **La posa iniziale non serve, ed è misurato.** Questo documento dava per scontato che `CompareDXF`
|
||||
dovesse partire allineato dalla posa di `MatchPattern`. Ruotando la scena e lasciando l'ICP senza
|
||||
aiuto, sul rettangolo forato a 4 px/mm: **0°** → 0,285 mm, **5°** → 0,346, **20°** → 0,288, **45°** →
|
||||
0,243. Converge da solo, e a 45° meglio che a zero. In più `DXFCompare._pose_to_transform` non ha
|
||||
chiamanti né test e trasla le **righe** dove il nome dice colonne: l'ingresso non è stato esposto,
|
||||
perché chi lo avesse collegato al match avrebbe ottenuto una traslazione di centinaia di pixel
|
||||
credendo di aver fatto la cosa giusta. Un test parametrico su 0°, 20° e 45° tiene ferma la scelta.
|
||||
|
||||
⚠️⚠️ **E una trappola nuova, da sapere prima di comporre ricette: su un pezzo simmetrico l'angolo del
|
||||
match è ambiguo.** Sulla scena non ruotata il rettangolo torna `angle_deg = -180` con punteggio 0,924,
|
||||
perché a 180° è identico a sé stesso. `row` e `col` restano affidabili; l'angolo, su un pezzo senza un
|
||||
riferimento che rompa la simmetria, non è una risposta. I profilati di Tràfilo sono esattamente il genere
|
||||
di pezzo in cui questo si presenta.
|
||||
|
||||
*Verificato:* `tests/test_visione_nel_grafo.py` carica una ricetta JSON che contiene **entrambi** gli
|
||||
strumenti con `Task.from_dict`, la esegue e ottiene le uscite attese. 21 test nuovi; **1242 verdi** su
|
||||
tutto il monorepo.
|
||||
|
||||
**Quello che resta da fare qui, in TieMeasureFlow** (è ciò che la issue #8 della lista lavori chiedeva, e
|
||||
non è cambiato): `vs-dxf` entra negli extra `vision` e `vision-worker` di `pyproject.toml`, il pin del
|
||||
sottomodulo avanza a `6f5990c`, e il runner **importa i due moduli** `visionsuite.pm2d.task_tools` e
|
||||
`visionsuite.dxf.task_tools` — senza quell'import il registro non li conosce e una ricetta che li nomina
|
||||
non si carica. Nessuna riga di algoritmo entra in questo repository.
|
||||
|
||||
*Fatto quando:* un grafo JSON che contiene `MatchPattern` e `CompareDXF` viene eseguito da
|
||||
`POST /api/vision/preview` su un'immagine caricata, e restituisce le uscite attese. Senza camera, senza
|
||||
interfaccia. E `test_vision_boundary.py` continua a passare.
|
||||
|
||||
### V2 — L'authoring nel Maker — **in fiera** (decisa il 14/09)
|
||||
|
||||
La schermata che oggi non esiste. Si compone con un **editor visuale a nodi** (decisione A, chiusa il
|
||||
14/09). Il Maker, alla scrivania:
|
||||
|
||||
- carica o sceglie un'**immagine di riferimento** per il task (l'API esiste già);
|
||||
- compone il grafo collegando gli strumenti — l'immagine entra, e da lì scendono il riconoscimento, le
|
||||
quote e il confronto col disegno;
|
||||
- preme **prova** e vede le uscite (`/preview` esiste già);
|
||||
- **aggancia** ciascuna uscita a una quota della ricetta, scrivendo `vision_output` sulla subtask (il
|
||||
campo esiste già), e su quella quota mette nominale e tolleranze come su qualunque altra;
|
||||
- **promuove le uscite della prova a `expected_json`**, che è ciò che accende la prova di non regressione
|
||||
(lacuna 4) e chiude una promessa che la spec di agosto ha fatto e non ha mantenuto.
|
||||
|
||||
**Il taglio per la fiera** (deciso il 14/09): ci va tutto **fino alla misura vera** — comporre, provare,
|
||||
agganciare alle quote, e il ciclo dell'operatore che misura e riceve OK/warning/fail con le tolleranze
|
||||
applicate. È la storia che vende: il confronto DXF **dentro il flusso di produzione**, non un tool a sé.
|
||||
Restano fuori dalla fiera, e si fanno dopo, la promozione a `expected_json` e la prova di non regressione:
|
||||
valgono per la robustezza, ma allo stand non si mostrano.
|
||||
|
||||
⚠️ **Un limite dell'editor, emerso il 14/09 costruendo gli strumenti.** Negli strumenti geometrici di
|
||||
`vs-task` gli ingressi di scansione — `start_row`, `start_col`, `end_row`, `end_col` di `DetectLine` e
|
||||
compagnia — **non sono collegabili**: si impostano a mano. Finché restano così, la posa che
|
||||
`MatchPattern` trova non può spostare le scansioni delle quote, e il pezzo va appoggiato dove la ricetta
|
||||
si aspetta. Renderli collegabili è un lavoro in VisionSuite, piccolo ma non nullo, ed è **il passo che
|
||||
rende utile il riconoscimento dentro una ricetta di misura**: senza, `MatchPattern` serve a dire se il
|
||||
pezzo c'è ed è quello giusto, non a inseguirlo.
|
||||
|
||||
*Fatto quando:* una ricetta con un task di visione si compone interamente dall'interfaccia, senza scrivere
|
||||
JSON a mano, e un operatore la esegue ottenendo un esito vero. La prova di non regressione — rieseguire
|
||||
l'intero parco ricette su immagini di riferimento e vedere quali quote si sono mosse — è il criterio della
|
||||
parte differita.
|
||||
|
||||
### V3 — L'agente di stazione, i device, la camera
|
||||
|
||||
È il cammino critico per Tràfilo, perché è ciò che rende il sistema utilizzabile in reparto.
|
||||
|
||||
- Tabella `station_devices` e migrazione, così com'è descritta nella spec di agosto (specchio in sola
|
||||
lettura, popolato dall'agente; un device che non si dichiara più è **offline**, non cancellato).
|
||||
- WebSocket `/api/stations/agent`: **è l'agente che apre la connessione**, il server non bussa mai a un PC
|
||||
di reparto. È la scelta che evita di aprire porte in fabbrica.
|
||||
- L'agente: sottile, monta `vs-core` e `vs-camera` e basta; acquisisce e carica il fotogramma. Camera
|
||||
**Itala** di Opto Engineering, il cui producer GenTL è già stato provato in VisionSuite e ha richiesto
|
||||
mezz'ora invece delle sei-dieci ore stimate.
|
||||
- `vision_results.image_path` e `overlay_path` finalmente scritte (lacuna 3), con l'overlay prodotto dal
|
||||
worker.
|
||||
|
||||
*Fatto quando:* un operatore misura un pezzo da una postazione vera, e la riga in `vision_results` porta
|
||||
immagine, overlay, device, taratura e versione del motore.
|
||||
|
||||
### V4 — L'esecuzione sulla stazione
|
||||
|
||||
La policy per stazione, il ripiego in locale quando il server non risponde, e il **rifiuto di misurare**
|
||||
quando la versione del motore non concorda con quella del server. Quest'ultimo non è un dettaglio di
|
||||
build: è la garanzia metrologica su cui poggia tutto il resto.
|
||||
|
||||
*Fatto quando:* una stazione con un pin discorde si rifiuta di misurare e lo dichiara, invece di produrre
|
||||
numeri plausibili.
|
||||
|
||||
### V5 — Il task `dxf_compare` come tipo a sé
|
||||
|
||||
Il valore `dxf_compare` esiste già nell'enum. Qui prende il suo comportamento: quote sintetiche derivate
|
||||
dal confronto (scarto massimo, numero di zone fuori tolleranza) trattate come misure normali, e il
|
||||
**rapporto allegato** che `visionsuite.dxf.report` sa già generare, archiviato accanto all'immagine.
|
||||
|
||||
## 7. Le decisioni che restano da prendere
|
||||
|
||||
Nessuna di queste si può dedurre dal codice: vanno chiuse da Adriano, alcune con il cliente.
|
||||
|
||||
**A. Come si compone un grafo — chiusa il 14/09/2026: un editor visuale a nodi.** Adriano lo ha rimesso
|
||||
nel perimetro, e la scelta **revoca la revoca del 09/08**. Allora TieWorkflow era uscito perché *«un editor
|
||||
ha senso solo se a comporre le pipeline è qualcuno che non programma, e il cliente aveva chiesto misure,
|
||||
non un editor»*: motivazione giusta quando il committente era Tràfilo, che vuole misurare profilati.
|
||||
Adesso il modulo è un investimento per prendere lavori futuri (decisione F), e il pubblico dello stand non
|
||||
è l'operatore di Tràfilo — è chi allo stand deve immaginarci il **proprio** pezzo, e a quello un form fisso
|
||||
non parla.
|
||||
|
||||
Le altre due strade restano scritte qui perché sono la via d'uscita, non perché siano state scelte: un
|
||||
**modulo per tipo di task** con i parametri in chiaro (il minimo che funziona) e una **libreria di grafi
|
||||
preconfezionati** che il Maker sceglie e tara.
|
||||
|
||||
⚠️⚠️ **È la più cara delle tre, su un cammino critico già pieno.** Ad agosto l'editor era stimato **12-18
|
||||
ore su un perimetro più piccolo** — senza `MatchPattern`, senza `CompareDXF`, senza l'aggancio quota→uscita
|
||||
— ed è l'ordine di grandezza che aveva fatto sfondare il budget del piano ferie. Quelle ore ora si sommano
|
||||
alla V1, anch'essa obbligatoria per la fiera, dentro 43 giorni. **Il ripiego va deciso adesso, non il 20
|
||||
ottobre:** se a metà ottobre l'editor non regge, si espone il modulo per tipo di task, che mostra le stesse
|
||||
misure con meno scena.
|
||||
|
||||
|
||||
**B. La scala: pixel o millimetri.** Il 09/08 si era deciso che le quote potessero restare **in pixel**, e
|
||||
che per il DXF compare servisse un fattore di scala ricavato da una quota nota misurata col calibro. Va
|
||||
confermato: sono due prodotti diversi da spiegare al cliente, e il secondo dipende dal test di
|
||||
calibrazione della fase V0.
|
||||
|
||||
**C. Il dimensionamento del server di Tràfilo.** Con l'esecuzione lato server come default, il pattern
|
||||
matching contende la CPU fra tutte le stazioni. La domanda alla D-4 non è più solo quale macchina e quanto
|
||||
disco: è anche quanti core e quanta RAM. Venti postazioni non sono tre.
|
||||
|
||||
**D. La conservazione delle immagini.** Quelle di riferimento sono poche e vivono quanto la ricetta;
|
||||
quelle di produzione sono una per pezzo e crescono senza limite. Serve una scadenza, o una regola del tipo
|
||||
«si conservano solo quelle dei pezzi fuori tolleranza». Senza, il disco del cliente si riempie e nessuno
|
||||
lo ha avvertito.
|
||||
|
||||
**E. Che cosa fa la stazione se la rete cade** e non è configurata per elaborare in locale: si blocca, o
|
||||
consente il calibro segnando il task come non eseguibile? È una domanda per Tràfilo.
|
||||
|
||||
**F. Chi paga — chiusa il 14/09/2026.** Nessuno, ed è una scelta. Il modulo di visione è un
|
||||
**investimento commerciale di Tielogic**: non si quota a Ricerca e Misure, non entra nel setup né nel
|
||||
canone dell'OFF-2026-022, e non aspetta un'offerta per cominciare. Serve a prendere lavori futuri, e il
|
||||
posto dove quell'investimento si trasforma in lavori è la fiera.
|
||||
|
||||
Ne discendono tre conseguenze che questo documento adotta:
|
||||
|
||||
1. **Il modulo non ha una data di consegna contrattuale**, quindi l'unica scadenza che lo governa è la
|
||||
fiera del 27-29 ottobre. La fase V1 è l'unica del percorso visione che ha una data vera.
|
||||
2. **Tràfilo va servito con il perimetro venduto** — le dodici modifiche del 28/07, l'installazione
|
||||
on-premise, l'agente di stazione — e quel lavoro non si rallenta per la visione: è quello che è già
|
||||
stato fatturato. Dove la visione e Tràfilo si contendono le stesse ore, vince ciò che è stato venduto,
|
||||
con l'unica eccezione della V1 prima della fiera.
|
||||
3. **Il confine va tenuto per iscritto**: le ore spese per la fiera sono a carico Tielogic, quelle spese
|
||||
per Tràfilo stanno nella commessa. Senza quella riga, a consuntivo il modulo di visione sembrerà un
|
||||
costo della commessa Ricerca e Misure e ne falserà il margine.
|
||||
|
||||
⚠️ Resta aperto un pezzo che non è «chi paga» ma «che cosa»: **cosa si mostra in fiera** non coincide con
|
||||
cosa si consegna. Una demo può girare su un grafo preparato, un pezzo noto e una posa controllata; un
|
||||
prodotto no. Deciderlo prima cambia l'ampiezza della V1.
|
||||
|
||||
## 8. Quello che non va fatto
|
||||
|
||||
- **Non scrivere algoritmi di visione in questo repository.** Il confine è sorvegliato da un test; se
|
||||
domani il server importasse VisionSuite per una scorciatoia, il vantaggio si perderebbe in silenzio.
|
||||
- **Non rifare l'interfaccia** oltre le schermate nuove che le fasi V2 e V3 richiedono.
|
||||
- **Non toccare SPC, ricette e integrazione GAIA.** GAIA è il gestionale del cliente finale e non è in
|
||||
questo perimetro.
|
||||
- **Non aggiungere il modulo AI** di cui si è parlato in call: è perimetro nuovo, senza offerta.
|
||||
- **Non far giudicare il grafo.** I verdetti di `vs-dxf` e di `vs-measure` restano inutilizzati: decide
|
||||
`measurement_service`.
|
||||
- **Non avanzare il pin del sottomodulo senza la prova di non regressione in piedi.** Con il motore
|
||||
installato su due host, un aggiornamento non verificato sposta i numeri senza che nessuno se ne accorga
|
||||
fino alla contestazione.
|
||||
|
||||
## 9. Il calendario, per capire le priorità
|
||||
|
||||
La catena è **Tielogic produce → Ricerca e Misure (Menoncin) rivende → Tràfilo usa**, venti postazioni.
|
||||
Due scadenze esterne, e dal 14/09 non pesano più allo stesso modo.
|
||||
|
||||
- **Fiera del 27-29 ottobre**, banchetto Tielogic nella zona Opto Engineering. Le servono **V1 e V2
|
||||
insieme**, perché allo stand si mostra il prodotto e non una demo: comporre una ricetta di visione
|
||||
dall'interfaccia, misurare un pezzo, vedere l'esito. È l'unica data del percorso visione, ed è il posto
|
||||
dove l'investimento della decisione F si trasforma in lavori.
|
||||
- **Installazione a Tràfilo**, attesa per settembre. Le serve la **V3**, ed è **ciò che è stato
|
||||
fatturato**. ⚠️ Con V1 e V2 entrambe prima della fiera, la V3 resta indietro: è una scelta consapevole,
|
||||
ma la tempistica dell'installazione era stata data a Menoncin, quindi va **detta a lui** invece di
|
||||
scoperta da lui.
|
||||
|
||||
⚠️ Il rischio non è che manchi una delle due: è che l'editor a nodi si prenda le ore della V1. Il ripiego
|
||||
è scritto nella decisione A, e va guardato a **metà ottobre**, quando cambiare strada costa ancora poco.
|
||||
+8
-9
@@ -34,13 +34,6 @@ server {
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# Upload files served by server
|
||||
location /uploads/ {
|
||||
proxy_pass http://server_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# All other requests → Flask client
|
||||
location / {
|
||||
proxy_pass http://client_frontend;
|
||||
@@ -50,12 +43,18 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Static file caching
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||
# Static file caching. Limitata a /static/: un add_header in questo
|
||||
# location spegne l'ereditarietà di quelli del blocco server (comportamento
|
||||
# nginx), quindi vanno ripetuti qui.
|
||||
location ~* ^/static/.*\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||
proxy_pass http://client_frontend;
|
||||
proxy_set_header Host $host;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# SSL configuration (uncomment when using Let's Encrypt)
|
||||
|
||||
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,53 @@
|
||||
<#
|
||||
Cosa fa: avvia TieMeasureFlow DA SORGENTE su una macchina di sviluppo Windows, senza la build
|
||||
Nuitka (che richiede ~20 minuti): stessi quattro server e stesse porte dell'exe (8780 web,
|
||||
8000 backend, 8100 visione, 8765 camera), stessa cartella dati %PROGRAMDATA%\Tielogic\TieMeasureFlow.
|
||||
Prima dell'avvio: ambiente Python 3.13 in build\venv313 (uv sync, solo con -Sincronizza o se manca),
|
||||
tailwind.css e traduzioni .mo ricompilati, DLL di WeasyPrint in build\gtk (scaricate con
|
||||
pdf\prendi-gtk.ps1 se mancano).
|
||||
|
||||
Come si usa (dalla radice del repo):
|
||||
powershell -ExecutionPolicy Bypass -File packaging\windows\sviluppo.ps1 [-Sincronizza] [-Ascolto 0.0.0.0] [-EscludiCamere OECS]
|
||||
Dopo una modifica: Ctrl+C e rilancio (pochi secondi). Template e JS non richiedono altro;
|
||||
classi Tailwind nuove e msgid nuovi vengono ricompilati a ogni avvio.
|
||||
|
||||
Dipende da: uv, npx (Node), la licenza tiemeasureflow di QUESTA macchina in
|
||||
%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json (impronta: avvio.py --impronta).
|
||||
#>
|
||||
param(
|
||||
[switch]$Sincronizza, # forza uv sync (dipendenze cambiate)
|
||||
[string]$Ascolto = '127.0.0.1', # indirizzo di ascolto della UI web ($Host e' riservato in PowerShell)
|
||||
[string]$EscludiCamere = '' # backend nativi da saltare (TieVision-Core#339): OECS se c'e' il runtime OECS ma in fiera si usa GenTL
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313' # stesso ambiente della build
|
||||
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
|
||||
|
||||
if ($Sincronizza -or -not (Test-Path $py)) {
|
||||
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
|
||||
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
|
||||
}
|
||||
|
||||
# CSS e traduzioni: non sono nel repo, come in build.ps1
|
||||
$flask = Join-Path $radice 'src\frontend\flask_app'
|
||||
Push-Location $flask
|
||||
try {
|
||||
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
|
||||
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') -q compile -d translations
|
||||
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
|
||||
} finally { Pop-Location }
|
||||
|
||||
# DLL di WeasyPrint fuori dalla radice (gtk\ nella radice non e' ignorata da git): build\gtk
|
||||
$gtk = Join-Path $radice 'build\gtk'
|
||||
if (-not (Test-Path (Join-Path $gtk '*.dll'))) {
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1') -Destinazione $gtk
|
||||
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
|
||||
}
|
||||
$env:WEASYPRINT_DLL_DIRECTORIES = $gtk
|
||||
|
||||
if ($EscludiCamere) { $env:VS_CAMERA_ESCLUDI = $EscludiCamere } # #339: stesso percorso camera del PC fiera
|
||||
Set-Location $radice
|
||||
& $py (Join-Path $PSScriptRoot 'avvio.py') --senza-browser --senza-finestre --host $Ascolto # errori in console, niente MessageBox
|
||||
exit $LASTEXITCODE
|
||||
@@ -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)]
|
||||
+21
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "tiemeasureflow"
|
||||
version = "2.0.0"
|
||||
version = "3.0.0"
|
||||
description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations."
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
@@ -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,781 @@
|
||||
"""Calibrazione a scacchiera dell'acquisitore (TieMeasureFlow, #83).
|
||||
|
||||
Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con
|
||||
TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente
|
||||
camera in un JSON (scrittura atomica).
|
||||
#91: le calibrazioni stanno in un archivio UNICO, salvate con nome (classe Archivio);
|
||||
una sorgente ne ha una attiva (attive.json) e il formato vecchio (una per sorgente)
|
||||
si migra da solo.
|
||||
#84: `calibra_scacchiera_densa(pixels, square_mm)` calibra da UNA foto di
|
||||
scacchiera che riempie tutto il campo (ottica telecentrica): trova da sola la
|
||||
griglia, costruisce la mappa di distorsione (DistortionMap) e il mm/px
|
||||
dell'immagine raddrizzata. L'Archivio salva la mappa accanto al JSON (.npz) e
|
||||
`raddrizza()` la applica ai frame.
|
||||
Uso: importato da scripts/camera_service.py (rotte /camera/calibrate e
|
||||
/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e
|
||||
`calibra_scacchiera_densa(gray, square_mm)` sono logica pura, senza FastAPI,
|
||||
e si provano da sole.
|
||||
Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro (no scipy).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import unicodedata # #91 caratteri di controllo nei nomi
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from visionsuite.calibration import calibrate_planar, find_chessboard
|
||||
from visionsuite.calibration.distortion_map import DistortionMap # #84 mappa densa
|
||||
from visionsuite.core.image import Image
|
||||
|
||||
|
||||
class CalibrazioneError(ValueError):
|
||||
"""Parametri invalidi o scacchiera non trovata: input sbagliato, non un guasto (422)."""
|
||||
|
||||
|
||||
def _gray_u8(pixels: np.ndarray) -> np.ndarray:
|
||||
"""Immagine -> grigio uint8 2D."""
|
||||
px = np.asarray(pixels)
|
||||
if px.ndim == 3:
|
||||
px = cv2.cvtColor(px, cv2.COLOR_BGRA2GRAY if px.shape[2] == 4 else cv2.COLOR_BGR2GRAY)
|
||||
if px.dtype != np.uint8:
|
||||
# float/uint16 -> scala sul massimo, come fa vs-core
|
||||
m = float(px.max()) if px.size else 1.0
|
||||
px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8)
|
||||
return np.ascontiguousarray(px)
|
||||
|
||||
|
||||
def calibra_scacchiera(pixels, cols: int, rows: int, square_mm: float) -> dict:
|
||||
"""Scala (px/mm) da una foto di scacchiera piana. cols/rows = angoli INTERNI.
|
||||
|
||||
Ritorna {px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm,
|
||||
image_width, image_height, scale_spread_pct, metodo, distorsione,
|
||||
overlay_png (b64)}.
|
||||
"""
|
||||
if cols < 2 or rows < 2:
|
||||
raise CalibrazioneError("cols e rows (angoli interni) devono essere almeno 2")
|
||||
if not (square_mm > 0 and math.isfinite(square_mm)):
|
||||
raise CalibrazioneError("square_mm deve essere > 0")
|
||||
|
||||
frame = _gray_u8(pixels)
|
||||
image = Image(frame)
|
||||
try:
|
||||
image_pts, world_pts = find_chessboard(image, (cols, rows), square_mm)
|
||||
except RuntimeError as exc:
|
||||
# find_chessboard solleva RuntimeError quando la scacchiera non c'e'
|
||||
raise CalibrazioneError(
|
||||
f"scacchiera {cols}x{rows} non trovata nell'immagine: {exc}") from exc
|
||||
model = calibrate_planar(image, image_pts, world_pts) # omografia pura
|
||||
|
||||
# Scala al centro della scacchiera (puo' non stare al centro immagine)
|
||||
x0, y0 = image_pts.min(axis=0)
|
||||
x1, y1 = image_pts.max(axis=0)
|
||||
mm_per_px = model.pixel_size((x0 + x1) / 2.0, (y0 + y1) / 2.0)
|
||||
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
|
||||
raise CalibrazioneError("calibrazione degenere: scala non valida")
|
||||
# Scarto massimo % della scala ai quattro angoli dell'area della scacchiera
|
||||
# rispetto al centro: ottica telecentrica/piana ~ 0, prospettiva = grande.
|
||||
spread = max(abs(model.pixel_size(x, y) - mm_per_px) / mm_per_px * 100.0
|
||||
for x in (x0, x1) for y in (y0, y1))
|
||||
|
||||
# Overlay: angoli disegnati sull'immagine (PNG base64)
|
||||
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||
cv2.drawChessboardCorners(
|
||||
bgr, (cols, rows), image_pts.reshape(-1, 1, 2).astype(np.float32), True)
|
||||
ok, png = cv2.imencode(".png", bgr)
|
||||
if not ok:
|
||||
raise RuntimeError("PNG dell'overlay non codificabile")
|
||||
|
||||
h, w = frame.shape
|
||||
return {
|
||||
"px_per_mm": 1.0 / mm_per_px,
|
||||
"mm_per_px": mm_per_px,
|
||||
"rms_px": float(model.rms_reprojection),
|
||||
"n_corners": int(len(image_pts)),
|
||||
"cols": int(cols),
|
||||
"rows": int(rows),
|
||||
"square_mm": float(square_mm),
|
||||
"image_width": int(w),
|
||||
"image_height": int(h),
|
||||
"scale_spread_pct": float(spread),
|
||||
"metodo": "planare", # #84: il metodo con cols/rows resta quello di prima
|
||||
"distorsione": False,
|
||||
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
# ---- #84: calibrazione densa (scacchiera che riempie il campo, griglia non nota)
|
||||
|
||||
# #84: findChessboardCornersSBWithMeta (verificato su opencv 5.0.0) su una scacchiera
|
||||
# grande quanto l'immagine trova solo frammenti (2064x1544 con quadrati da 40 px:
|
||||
# 5x5, 28x6...). Per questo la griglia si cerca qui: sellini della Hessiana a piu'
|
||||
# scale + crescita del reticolo; poi cornerSubPix.
|
||||
_SIGME = (1.5, 2.2, 3.2, 4.5, 6.5, 9.0, 13.0) # scale dei sellini (passo ~ 5..120 px)
|
||||
_MAX_CANDIDATI = 6000
|
||||
|
||||
|
||||
def _candidati(gray: np.ndarray, sigma: float) -> np.ndarray:
|
||||
"""Massimi locali del 'selliness' (-det Hessiana) = angoli di scacchiera, (n,2) x,y."""
|
||||
b = cv2.GaussianBlur(gray.astype(np.float32), (0, 0), sigma)
|
||||
ixx = cv2.Sobel(b, cv2.CV_32F, 2, 0, ksize=3)
|
||||
iyy = cv2.Sobel(b, cv2.CV_32F, 0, 2, ksize=3)
|
||||
ixy = cv2.Sobel(b, cv2.CV_32F, 1, 1, ksize=3)
|
||||
r = ixy * ixy - ixx * iyy # >0 solo dove l'immagine ha un sellino
|
||||
massimo = float(r.max())
|
||||
if massimo <= 0:
|
||||
return np.zeros((0, 2))
|
||||
k = 2 * int(math.ceil(1.5 * sigma)) + 1
|
||||
picchi = (r >= cv2.dilate(r, np.ones((k, k), np.uint8))) & (r > 0.1 * massimo)
|
||||
ys, xs = np.nonzero(picchi)
|
||||
if len(xs) > _MAX_CANDIDATI: # tiene i piu' forti
|
||||
forti = np.argsort(r[ys, xs])[::-1][:_MAX_CANDIDATI]
|
||||
ys, xs = ys[forti], xs[forti]
|
||||
return np.stack([xs, ys], axis=1).astype(np.float64)
|
||||
|
||||
|
||||
def _e_angolo_x(lisc: np.ndarray, delta: float, p: np.ndarray, u: np.ndarray, v: np.ndarray) -> bool:
|
||||
"""True se attorno a p i quattro quadranti alternano chiaro/scuro (vero angolo X).
|
||||
|
||||
Scarta le giunzioni a L/T al bordo della scacchiera (3 quadranti uguali), che
|
||||
il selliness da solo accetta e che spostano la griglia.
|
||||
"""
|
||||
h, w = lisc.shape
|
||||
campioni = []
|
||||
for q in (0.25 * (u + v), -0.25 * (u + v), 0.25 * (u - v), -0.25 * (u - v)):
|
||||
x, y = int(round(p[0] + q[0])), int(round(p[1] + q[1]))
|
||||
if not (0 <= x < w and 0 <= y < h):
|
||||
return False
|
||||
campioni.append(float(lisc[y, x]))
|
||||
s1, s2, t1, t2 = campioni
|
||||
return (min(s1, s2) > max(t1, t2) + delta) or (max(s1, s2) < min(t1, t2) - delta)
|
||||
|
||||
|
||||
def _cresci_reticolo(pts: np.ndarray, centro: tuple[float, float],
|
||||
lisc: np.ndarray, delta: float) -> dict | None:
|
||||
"""Assegna un indice (riga, colonna) ai candidati che stanno su un reticolo.
|
||||
|
||||
Parte dal candidato piu' vicino al centro, ricava i due vettori di passo dai
|
||||
vicini e cresce a macchia d'olio: la base si aggiorna a ogni passo, quindi
|
||||
segue una distorsione lenta. Ritorna {(i, j): indice candidato} o None.
|
||||
"""
|
||||
if len(pts) < 9:
|
||||
return None
|
||||
seme = int(np.argmin(np.hypot(pts[:, 0] - centro[0], pts[:, 1] - centro[1])))
|
||||
d = np.hypot(pts[:, 0] - pts[seme, 0], pts[:, 1] - pts[seme, 1])
|
||||
vicini = np.argsort(d)[1:9]
|
||||
u = pts[vicini[0]] - pts[seme]
|
||||
lu = float(np.hypot(*u))
|
||||
v = None
|
||||
for k in vicini[1:]:
|
||||
w = pts[k] - pts[seme]
|
||||
lw = float(np.hypot(*w))
|
||||
# il secondo vettore: quasi ortogonale al primo e di lunghezza simile
|
||||
if lu > 0 and lw > 0 and abs(float(u @ w)) / (lu * lw) < 0.4 and lw < 1.5 * lu:
|
||||
v = w
|
||||
break
|
||||
if v is None or lu < 3:
|
||||
return None
|
||||
posto = {(0, 0): seme}
|
||||
indice = {seme: (0, 0)}
|
||||
basi = {seme: (u, v)}
|
||||
coda = deque([seme])
|
||||
while coda:
|
||||
a = coda.popleft()
|
||||
i, j = indice[a]
|
||||
u, v = basi[a]
|
||||
tol = 0.3 * min(float(np.hypot(*u)), float(np.hypot(*v)))
|
||||
for di, dj, vec in ((0, 1, u), (0, -1, -u), (1, 0, v), (-1, 0, -v)):
|
||||
if (i + di, j + dj) in posto:
|
||||
continue
|
||||
prev = pts[a] + vec
|
||||
dist = np.hypot(pts[:, 0] - prev[0], pts[:, 1] - prev[1])
|
||||
b = int(np.argmin(dist))
|
||||
if dist[b] > tol or b in indice:
|
||||
continue
|
||||
if not _e_angolo_x(lisc, delta, pts[b], u, v): # niente L/T del bordo
|
||||
continue
|
||||
posto[(i + di, j + dj)] = b
|
||||
indice[b] = (i + di, j + dj)
|
||||
reale = pts[b] - pts[a]
|
||||
basi[b] = (reale * dj, v) if dj else (u, reale * di) # base locale aggiornata
|
||||
coda.append(b)
|
||||
return posto
|
||||
|
||||
|
||||
def _rettangolo_pieno(posto: dict) -> tuple[int, int, int, int] | None:
|
||||
"""Il rettangolo di indici tutto occupato di area massima: (i0, j0, righe, colonne)."""
|
||||
if not posto:
|
||||
return None
|
||||
i_min = min(i for i, _ in posto)
|
||||
j_min = min(j for _, j in posto)
|
||||
nr = max(i for i, _ in posto) - i_min + 1
|
||||
nc = max(j for _, j in posto) - j_min + 1
|
||||
m = np.zeros((nr, nc), bool)
|
||||
for (i, j) in posto:
|
||||
m[i - i_min, j - j_min] = True
|
||||
migliore = (0, 0, 0, 0)
|
||||
alt = np.zeros(nc, int)
|
||||
for r in range(nr): # istogramma delle altezze + pila, O(righe*colonne)
|
||||
alt = np.where(m[r], alt + 1, 0)
|
||||
pila: list[int] = []
|
||||
for c in range(nc + 1):
|
||||
h = int(alt[c]) if c < nc else 0
|
||||
while pila and alt[pila[-1]] >= h:
|
||||
altezza = int(alt[pila.pop()])
|
||||
sx = pila[-1] + 1 if pila else 0
|
||||
larg = c - sx
|
||||
if altezza * larg > migliore[2] * migliore[3]:
|
||||
migliore = (r - altezza + 1 + i_min, sx + j_min, altezza, larg)
|
||||
pila.append(c)
|
||||
return migliore if migliore[2] * migliore[3] else None
|
||||
|
||||
|
||||
def _orienta(g: np.ndarray) -> np.ndarray:
|
||||
"""Riordina (nr,nc,2) con colonne verso destra e righe verso il basso."""
|
||||
a = (g[:, -1] - g[:, 0]).mean(axis=0) # direzione dell'indice di colonna
|
||||
b = (g[-1, :] - g[0, :]).mean(axis=0) # direzione dell'indice di riga
|
||||
if abs(b[0]) > abs(a[0]): # assi scambiati (scacchiera ruotata di ~90 gradi)
|
||||
g = g.transpose(1, 0, 2)
|
||||
a, b = b, a
|
||||
if a[0] < 0:
|
||||
g = g[:, ::-1]
|
||||
if b[1] < 0:
|
||||
g = g[::-1]
|
||||
return np.ascontiguousarray(g)
|
||||
|
||||
|
||||
def _passo_mediano(g: np.ndarray) -> float:
|
||||
"""Distanza mediana (px) fra corner adiacenti."""
|
||||
passi = np.concatenate([np.diff(g, axis=1).reshape(-1, 2), np.diff(g, axis=0).reshape(-1, 2)])
|
||||
return float(np.median(np.hypot(passi[:, 0], passi[:, 1])))
|
||||
|
||||
|
||||
def _raffina(img: np.ndarray, pts: np.ndarray, passo: float) -> np.ndarray:
|
||||
"""cornerSubPix su (n,2) con finestra ~0.3 passo, ristretta per chi sta vicino al bordo.
|
||||
|
||||
Una finestra che esce dall'immagine vede il bordo replicato e sposta il corner
|
||||
di ~1 px (visto sull'ultima colonna di una scacchiera a 14 px dal bordo): per
|
||||
questi punti si usa la finestra piu' grande che sta dentro; sotto 3 px resta
|
||||
la posizione grezza.
|
||||
"""
|
||||
h, w = img.shape
|
||||
out = pts.astype(np.float32).copy()
|
||||
win = int(min(20, max(3, 0.3 * passo)))
|
||||
dentro = np.minimum.reduce([out[:, 0], w - 1 - out[:, 0], out[:, 1], h - 1 - out[:, 1]])
|
||||
per_punto = np.minimum(win, np.floor(dentro).astype(int) - 2)
|
||||
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 40, 0.001)
|
||||
for wi in np.unique(per_punto[per_punto >= 3]):
|
||||
sel = per_punto == wi
|
||||
out[sel] = cv2.cornerSubPix(img, out[sel].reshape(-1, 1, 2).copy(),
|
||||
(int(wi), int(wi)), (-1, -1), crit).reshape(-1, 2)
|
||||
return out
|
||||
|
||||
|
||||
def _trova_griglia(frame: np.ndarray) -> np.ndarray:
|
||||
"""Griglia di angoli (n_righe, n_colonne, 2) x,y, in ordine di riga dall'alto."""
|
||||
h, w = frame.shape
|
||||
migliore = None
|
||||
lisc = cv2.GaussianBlur(frame.astype(np.float32), (0, 0), 1.0)
|
||||
p5, p95 = np.percentile(frame, (5, 95))
|
||||
delta = 0.25 * float(p95 - p5) # contrasto minimo fra quadranti opposti
|
||||
for sigma in _SIGME:
|
||||
pts = _candidati(frame, sigma)
|
||||
posto = _cresci_reticolo(pts, (w / 2.0, h / 2.0), lisc, delta)
|
||||
rett = _rettangolo_pieno(posto) if posto else None
|
||||
if rett is None:
|
||||
continue
|
||||
i0, j0, nr, nc = rett
|
||||
if nr < 3 or nc < 3:
|
||||
continue
|
||||
if migliore is not None and nr * nc < 0.5 * migliore[0] * migliore[1]:
|
||||
break # oltre il picco: le scale piu' grosse fondono gli angoli
|
||||
if migliore is None or nr * nc > migliore[0] * migliore[1]:
|
||||
g = np.array([[pts[posto[(i0 + i, j0 + j)]] for j in range(nc)]
|
||||
for i in range(nr)])
|
||||
migliore = (nr, nc, g)
|
||||
if migliore is None:
|
||||
raise CalibrazioneError(
|
||||
"griglia della scacchiera non trovata: serve una scacchiera nitida "
|
||||
"che riempia il campo")
|
||||
g = _orienta(migliore[2])
|
||||
nr, nc, _ = g.shape
|
||||
passo = _passo_mediano(g)
|
||||
# sub-pixel: finestra proporzionale al passo, abbastanza piccola da non vedere i vicini
|
||||
grezzi = g.reshape(-1, 2).astype(np.float32)
|
||||
raff = _raffina(frame, grezzi, passo)
|
||||
spostati = np.hypot(*(raff - grezzi).T) > 0.3 * passo
|
||||
raff[spostati] = grezzi[spostati] # un raffinamento che scappa e' peggio del grezzo
|
||||
return raff.reshape(nr, nc, 2).astype(np.float64)
|
||||
|
||||
|
||||
def _estrapola(g: np.ndarray, k: int, prima: bool) -> np.ndarray:
|
||||
"""Aggiunge k colonne (asse 1) per estrapolazione lineare, prima o dopo."""
|
||||
if k <= 0:
|
||||
return g
|
||||
if prima:
|
||||
passo = g[:, 0] - g[:, 1]
|
||||
extra = np.stack([g[:, 0] + m * passo for m in range(k, 0, -1)], axis=1)
|
||||
return np.concatenate([extra, g], axis=1)
|
||||
passo = g[:, -1] - g[:, -2]
|
||||
extra = np.stack([g[:, -1] + m * passo for m in range(1, k + 1)], axis=1)
|
||||
return np.concatenate([g, extra], axis=1)
|
||||
|
||||
|
||||
def _estendi_asse(g: np.ndarray, lungh: int, c: int, passo: float,
|
||||
grid_step: int) -> tuple[np.ndarray, int]:
|
||||
"""Estende la griglia (asse 1) fino a coprire l'immagine; ritorna (griglia, k_prima).
|
||||
|
||||
`c` e' la coordinata (0 = x, 1 = y) e `lungh` la dimensione dell'immagine su
|
||||
quell'asse. `from_grid` (crop_domain=False) tiene costante la mappa fuori
|
||||
dalla griglia: senza anelli in piu' i bordi dell'immagine raddrizzata
|
||||
sarebbero sbavati.
|
||||
"""
|
||||
margine = max(0.0, float(g[:, 0, c].min()))
|
||||
k_pre = int(math.ceil(margine / passo)) + 1
|
||||
g = _estrapola(g, k_pre, True)
|
||||
offset = int(g[0, 0, c]) # lo stesso int() che usa from_grid
|
||||
# colonne totali perche' l'ultimo nodo, a passo grid_step, superi il bordo
|
||||
servono = int(math.ceil((lungh - 1 - offset) / grid_step)) + 2
|
||||
g = _estrapola(g, max(0, servono - g.shape[1]), False)
|
||||
return g, k_pre
|
||||
|
||||
|
||||
class MappaPronta(NamedTuple):
|
||||
"""Mappa gia' convertita in virgola fissa per un cv2.remap veloce (#84)."""
|
||||
map1: np.ndarray
|
||||
map2: np.ndarray
|
||||
pixel_size: float
|
||||
shape: tuple # (altezza, larghezza) dei frame per cui vale
|
||||
|
||||
|
||||
def _prepara_mappa(mappa: DistortionMap) -> MappaPronta:
|
||||
# CV_16SC2: remap bilineare in virgola fissa, piu' veloce di float32 su 5 MP
|
||||
m1, m2 = cv2.convertMaps(mappa.map_col, mappa.map_row, cv2.CV_16SC2)
|
||||
return MappaPronta(m1, m2, float(mappa.pixel_size), mappa.shape)
|
||||
|
||||
|
||||
def raddrizza(frame: np.ndarray, mappa: MappaPronta) -> np.ndarray | None:
|
||||
"""Frame raddrizzato, o None se le dimensioni non sono quelle della calibrazione."""
|
||||
if frame.shape[:2] != tuple(mappa.shape):
|
||||
return None
|
||||
return cv2.remap(frame, mappa.map1, mappa.map2, cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
||||
|
||||
|
||||
def calibra_scacchiera_densa(pixels, square_mm: float) -> tuple[dict, DistortionMap]:
|
||||
"""Calibra da UNA foto di scacchiera densa (riempie il campo). Input: solo square_mm.
|
||||
|
||||
Ritorna (risultato, mappa). Risultato: {px_per_mm, mm_per_px (dell'immagine
|
||||
RADDRIZZATA), rms_px, residuo_rms_px, residuo_max_px, n_corners, cols, rows,
|
||||
square_mm, image_width, image_height, scale_spread_pct (0: la mappa uniforma
|
||||
la scala), copertura_pct, metodo, distorsione, overlay_png (b64)}.
|
||||
"""
|
||||
if not (square_mm > 0 and math.isfinite(square_mm)):
|
||||
raise CalibrazioneError("square_mm deve essere > 0")
|
||||
frame = _gray_u8(pixels)
|
||||
h, w = frame.shape
|
||||
g = _trova_griglia(frame)
|
||||
nr, nc, _ = g.shape
|
||||
passo = _passo_mediano(g)
|
||||
grid_step = int(round(passo))
|
||||
if grid_step < 2:
|
||||
raise CalibrazioneError("calibrazione degenere: passo della griglia troppo piccolo")
|
||||
|
||||
# anelli in piu' ai bordi (x e poi y), poi la mappa dai punti estesi
|
||||
e, kl = _estendi_asse(g, w, 0, passo, grid_step)
|
||||
et, kt = _estendi_asse(e.transpose(1, 0, 2), h, 1, passo, grid_step)
|
||||
e = et.transpose(1, 0, 2)
|
||||
try:
|
||||
mappa = DistortionMap.from_grid(
|
||||
e[..., 0].ravel(), e[..., 1].ravel(), e.shape[1], e.shape[0],
|
||||
image_size=(w, h), grid_step=grid_step, crop_domain=False,
|
||||
grid_step_measure=square_mm)
|
||||
except ValueError as exc:
|
||||
raise CalibrazioneError(f"mappa di distorsione non costruibile: {exc}") from exc
|
||||
mm_per_px = float(mappa.pixel_size)
|
||||
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
|
||||
raise CalibrazioneError("calibrazione degenere: scala non valida")
|
||||
|
||||
# qualita': si raddrizza la stessa foto e si rilegge ogni corner nel punto ideale
|
||||
off_c, off_r = int(e[0, 0, 0]), int(e[0, 0, 1])
|
||||
ideali = np.array([[(off_c + (kl + j) * grid_step, off_r + (kt + i) * grid_step)
|
||||
for j in range(nc)] for i in range(nr)], np.float32).reshape(-1, 1, 2)
|
||||
rett = cv2.remap(frame, mappa.map_col, mappa.map_row, cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
||||
# solo i corner ideali dentro l'immagine raddrizzata (cornerSubPix rifiuta quelli fuori)
|
||||
xy = ideali.reshape(-1, 2)
|
||||
dentro = (xy[:, 0] >= 1) & (xy[:, 0] <= w - 2) & (xy[:, 1] >= 1) & (xy[:, 1] <= h - 2)
|
||||
if int(dentro.sum()) < 4:
|
||||
raise CalibrazioneError("griglia raddrizzata fuori dall'immagine: calibrazione non verificabile")
|
||||
ideali = ideali[dentro].reshape(-1, 2)
|
||||
letti = _raffina(rett, ideali, grid_step)
|
||||
scarto = (letti - ideali).astype(np.float64)
|
||||
scarto -= np.median(scarto, axis=0) # int() dell'offset sposta tutto di una traslazione
|
||||
dist = np.hypot(scarto[:, 0], scarto[:, 1])
|
||||
residuo_max = float(dist.max())
|
||||
residuo_rms = float(math.sqrt(float((dist ** 2).mean())))
|
||||
|
||||
# copertura: area del quadrilatero dei quattro angoli esterni / area immagine
|
||||
quad = np.array([g[0, 0], g[0, -1], g[-1, -1], g[-1, 0]], np.float32)
|
||||
copertura = float(cv2.contourArea(quad)) / float(w * h) * 100.0
|
||||
|
||||
# overlay: corner trovati sull'originale (ridotto a max 1600 px: 5 MP in PNG pesano)
|
||||
f = min(1.0, 1600.0 / w)
|
||||
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||
pts = g.reshape(-1, 1, 2).astype(np.float32)
|
||||
if f < 1.0:
|
||||
bgr = cv2.resize(bgr, None, fx=f, fy=f, interpolation=cv2.INTER_AREA)
|
||||
pts = pts * np.float32(f)
|
||||
cv2.drawChessboardCorners(bgr, (nc, nr), pts, True)
|
||||
ok, png = cv2.imencode(".png", bgr)
|
||||
if not ok:
|
||||
raise RuntimeError("PNG dell'overlay non codificabile")
|
||||
|
||||
return {
|
||||
"px_per_mm": 1.0 / mm_per_px,
|
||||
"mm_per_px": mm_per_px,
|
||||
"rms_px": residuo_rms,
|
||||
"residuo_rms_px": residuo_rms,
|
||||
"residuo_max_px": residuo_max,
|
||||
"n_corners": int(nr * nc),
|
||||
"cols": int(nc),
|
||||
"rows": int(nr),
|
||||
"square_mm": float(square_mm),
|
||||
"image_width": int(w),
|
||||
"image_height": int(h),
|
||||
"scale_spread_pct": 0.0,
|
||||
"copertura_pct": copertura,
|
||||
"metodo": "densa",
|
||||
"distorsione": True,
|
||||
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
|
||||
}, mappa
|
||||
|
||||
|
||||
def ora_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class NomeNonValido(ValueError):
|
||||
"""#91: nome di calibrazione vuoto, troppo lungo o con caratteri di controllo (400)."""
|
||||
|
||||
|
||||
class NomeGiaUsato(ValueError):
|
||||
"""#91: esiste gia' una calibrazione con questo nome (senza distinguere maiuscole) (409)."""
|
||||
|
||||
|
||||
class NomeSconosciuto(KeyError):
|
||||
"""#91: nessuna calibrazione con questo nome (404)."""
|
||||
|
||||
|
||||
def pulisci_nome(nome) -> str:
|
||||
"""#91: nome valido = strip, 1-60 caratteri, niente caratteri di controllo."""
|
||||
if not isinstance(nome, str):
|
||||
raise NomeNonValido("il nome della calibrazione deve essere un testo")
|
||||
nome = nome.strip()
|
||||
if not nome:
|
||||
raise NomeNonValido("il nome della calibrazione è vuoto")
|
||||
if len(nome) > 60:
|
||||
raise NomeNonValido("il nome della calibrazione supera i 60 caratteri")
|
||||
if any(unicodedata.category(c) == "Cc" for c in nome):
|
||||
raise NomeNonValido("il nome della calibrazione contiene caratteri di controllo")
|
||||
return nome
|
||||
|
||||
|
||||
# #91: campi che l'elenco espone per ogni calibrazione (il resto resta nel JSON)
|
||||
_CAMPI_ELENCO = ("nome", "origine", "image_width", "image_height", "px_per_mm",
|
||||
"mm_per_px", "metodo", "square_mm", "calibrated_at")
|
||||
|
||||
|
||||
class Archivio:
|
||||
"""#91: archivio UNICO di calibrazioni con nome, in `cartella`.
|
||||
|
||||
Libreria: <cartella>/calibrazioni/<nome ripulito>-<sha1 del nome>.json (+ .npz se
|
||||
densa, stesso nome). Attive: <cartella>/attive.json = {sorgente: nome}. Una
|
||||
calibrazione non appartiene a una sorgente: la sorgente ne sceglie una (`attiva`).
|
||||
#84: `leggi_mappa` tiene la mappa in memoria finche' il file non cambia.
|
||||
Il formato di prima (un JSON per sorgente, campo "source", in <cartella>) si
|
||||
migra da solo al costruttore (`migra`, idempotente).
|
||||
"""
|
||||
|
||||
def __init__(self, cartella: Path | str):
|
||||
self.cartella = Path(cartella)
|
||||
self._cache: dict = {} # percorso .npz -> (mtime_ns, MappaPronta)
|
||||
self._lock = threading.Lock()
|
||||
self._lock_dati = threading.RLock() # #91: libreria e attive.json (leggi-modifica-scrivi)
|
||||
self.migra()
|
||||
|
||||
# ---- percorsi
|
||||
|
||||
def _file(self, sorgente: str) -> Path:
|
||||
# caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano
|
||||
# (#91: oggi serve ai settaggi camera e al formato vecchio da migrare)
|
||||
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", sorgente).strip("._") or "sorgente"
|
||||
sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8]
|
||||
return self.cartella / f"{pulito[:60]}-{sigla}.json"
|
||||
|
||||
@property
|
||||
def _libreria(self) -> Path:
|
||||
return self.cartella / "calibrazioni"
|
||||
|
||||
def _file_nome(self, nome: str) -> Path:
|
||||
# #91: stesso schema di _file, ma l'hash e' sul nome senza maiuscole/minuscole
|
||||
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", nome).strip("._") or "calibrazione"
|
||||
sigla = hashlib.sha1(nome.casefold().encode("utf-8")).hexdigest()[:8]
|
||||
return self._libreria / f"{pulito[:60]}-{sigla}.json"
|
||||
|
||||
def _file_mappa_nome(self, nome: str) -> Path:
|
||||
return self._file_nome(nome).with_suffix(".npz") # #84 stesso nome del JSON
|
||||
|
||||
@property
|
||||
def _file_attive(self) -> Path:
|
||||
return self.cartella / "attive.json"
|
||||
|
||||
# ---- scrittura atomica
|
||||
|
||||
def _scrivi_json(self, destinazione: Path, dati: dict) -> None:
|
||||
destinazione.parent.mkdir(parents=True, exist_ok=True)
|
||||
# scrittura atomica: file temporaneo nella stessa cartella, poi replace
|
||||
fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(dati, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, destinazione)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _leggi_json(self, percorso: Path) -> dict | None:
|
||||
try:
|
||||
dati = json.loads(percorso.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None # assente o illeggibile
|
||||
return dati if isinstance(dati, dict) else None
|
||||
|
||||
# ---- attive
|
||||
|
||||
def _leggi_attive(self) -> dict:
|
||||
dati = self._leggi_json(self._file_attive) or {}
|
||||
return {k: v for k, v in dati.items() if isinstance(k, str) and isinstance(v, str)}
|
||||
|
||||
def _scrivi_attive(self, attive: dict) -> None:
|
||||
self._scrivi_json(self._file_attive, attive)
|
||||
|
||||
# ---- migrazione dal formato vecchio
|
||||
|
||||
def migra(self) -> None:
|
||||
"""#91: ogni <cartella>/*.json con "source" (una per sorgente) entra in libreria.
|
||||
|
||||
Nome = valore di "source" (gia' preso: "<source> (2)", "(3)"...); il .npz
|
||||
accanto segue; la sorgente resta calibrata con quella voce (attive.json).
|
||||
Idempotente: il vecchio file sparisce, un secondo giro non trova nulla.
|
||||
"""
|
||||
if not self.cartella.is_dir():
|
||||
return
|
||||
with self._lock_dati:
|
||||
for vecchio in sorted(self.cartella.glob("*.json")):
|
||||
if vecchio.name == self._file_attive.name:
|
||||
continue
|
||||
dati = self._leggi_json(vecchio)
|
||||
sorgente = dati.get("source") if dati else None
|
||||
if not isinstance(sorgente, str) or not sorgente.strip():
|
||||
continue
|
||||
try:
|
||||
self._migra_uno(vecchio, dati, sorgente)
|
||||
except OSError:
|
||||
continue # un file bloccato non ferma il servizio: riprova al prossimo avvio
|
||||
|
||||
def _migra_uno(self, vecchio: Path, dati: dict, sorgente: str) -> None:
|
||||
base = "".join(c for c in sorgente if unicodedata.category(c) != "Cc").strip()[:60].strip() or "calibrazione"
|
||||
nome, n = base, 1
|
||||
while True:
|
||||
esistente = self.leggi_nome(nome)
|
||||
if esistente is None:
|
||||
break
|
||||
if (esistente.get("calibrated_at") == dati.get("calibrated_at")
|
||||
and esistente.get("px_per_mm") == dati.get("px_per_mm")):
|
||||
break # gia' copiata da un giro interrotto: si completa e basta
|
||||
n += 1
|
||||
suffisso = f" ({n})"
|
||||
nome = base[:60 - len(suffisso)].rstrip() + suffisso
|
||||
nuovo = {k: v for k, v in dati.items() if k != "source"}
|
||||
nuovo["nome"] = nome
|
||||
nuovo.setdefault("origine", sorgente) # l'origine vera non era registrata
|
||||
vecchia_mappa = vecchio.with_suffix(".npz")
|
||||
if vecchia_mappa.is_file(): # prima la mappa, poi il JSON che la dichiara
|
||||
self._libreria.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(vecchia_mappa, self._file_mappa_nome(nome))
|
||||
self._scrivi_json(self._file_nome(nome), nuovo)
|
||||
attive = self._leggi_attive()
|
||||
attive[sorgente.strip()] = nome
|
||||
self._scrivi_attive(attive)
|
||||
vecchio.unlink()
|
||||
|
||||
# ---- libreria
|
||||
|
||||
def leggi_nome(self, nome: str) -> dict | None:
|
||||
"""#91: la calibrazione con questo nome (senza distinguere maiuscole), o None."""
|
||||
try:
|
||||
nome = pulisci_nome(nome)
|
||||
except NomeNonValido:
|
||||
return None
|
||||
return self._leggi_json(self._file_nome(nome))
|
||||
|
||||
def _meta(self, dati: dict, attive: dict) -> dict:
|
||||
meta = {k: dati.get(k) for k in _CAMPI_ELENCO}
|
||||
chiave = str(dati.get("nome", "")).casefold()
|
||||
meta["attiva_su"] = sorted(s for s, n in attive.items() if n.casefold() == chiave)
|
||||
return meta
|
||||
|
||||
def elenco(self) -> list[dict]:
|
||||
"""#91: metadati di tutte le calibrazioni, dalla piu' recente."""
|
||||
with self._lock_dati:
|
||||
attive = self._leggi_attive()
|
||||
voci = []
|
||||
for percorso in self._libreria.glob("*.json") if self._libreria.is_dir() else []:
|
||||
dati = self._leggi_json(percorso)
|
||||
if dati and isinstance(dati.get("nome"), str):
|
||||
voci.append(self._meta(dati, attive))
|
||||
voci.sort(key=lambda m: str(m.get("calibrated_at") or ""), reverse=True)
|
||||
return voci
|
||||
|
||||
def metadati(self, nome: str) -> dict | None:
|
||||
"""#91: i metadati di una voce (come in `elenco`), o None."""
|
||||
dati = self.leggi_nome(nome)
|
||||
return self._meta(dati, self._leggi_attive()) if dati else None
|
||||
|
||||
def salva_nome(self, nome: str, dati: dict, mappa: DistortionMap | None = None,
|
||||
sovrascrivi: bool = False) -> dict:
|
||||
"""#91: salva `dati` (e la mappa, se densa) con questo nome; ritorna i metadati."""
|
||||
nome = pulisci_nome(nome)
|
||||
destinazione = self._file_nome(nome)
|
||||
with self._lock_dati:
|
||||
if destinazione.exists() and not sovrascrivi:
|
||||
esistente = self._leggi_json(destinazione) or {}
|
||||
raise NomeGiaUsato(f"esiste già una calibrazione «{esistente.get('nome', nome)}»")
|
||||
corpo = {k: v for k, v in dati.items() if k not in ("source", "overlay_png", "saved")}
|
||||
corpo["nome"] = nome
|
||||
self._libreria.mkdir(parents=True, exist_ok=True)
|
||||
# #84: prima la mappa (o la rimozione di una vecchia), poi il JSON che la dichiara
|
||||
if mappa is not None:
|
||||
self._salva_mappa(nome, mappa)
|
||||
else:
|
||||
self._togli_mappa(nome) # voce planare: una vecchia mappa non vale piu'
|
||||
self._scrivi_json(destinazione, corpo)
|
||||
return self._meta(corpo, self._leggi_attive())
|
||||
|
||||
def _salva_mappa(self, nome: str, mappa: DistortionMap) -> None:
|
||||
destinazione = self._file_mappa_nome(nome)
|
||||
fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp.npz") # .npz: numpy non aggiunge nulla
|
||||
os.close(fd)
|
||||
try:
|
||||
mappa.save(tmp)
|
||||
os.replace(tmp, destinazione) # atomica come il JSON
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
with self._lock:
|
||||
self._cache.pop(str(destinazione), None)
|
||||
|
||||
def _togli_mappa(self, nome: str) -> None:
|
||||
destinazione = self._file_mappa_nome(nome)
|
||||
try:
|
||||
destinazione.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
with self._lock:
|
||||
self._cache.pop(str(destinazione), None)
|
||||
|
||||
def elimina_nome(self, nome: str) -> bool:
|
||||
"""#91: toglie la voce (JSON e mappa) e la scollega da ogni sorgente. False se non c'era."""
|
||||
dati = self.leggi_nome(nome)
|
||||
if dati is None:
|
||||
return False
|
||||
canonico = str(dati.get("nome", nome))
|
||||
with self._lock_dati:
|
||||
try:
|
||||
self._file_nome(canonico).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self._togli_mappa(canonico)
|
||||
attive = self._leggi_attive()
|
||||
restanti = {s: n for s, n in attive.items() if n.casefold() != canonico.casefold()}
|
||||
if restanti != attive:
|
||||
self._scrivi_attive(restanti)
|
||||
return True
|
||||
|
||||
# ---- sorgenti
|
||||
|
||||
def attiva(self, sorgente: str, nome: str) -> str:
|
||||
"""#91: la sorgente usa questa calibrazione; ritorna il nome com'e' in libreria."""
|
||||
dati = self.leggi_nome(nome)
|
||||
if dati is None:
|
||||
raise NomeSconosciuto(nome)
|
||||
canonico = str(dati["nome"])
|
||||
with self._lock_dati:
|
||||
attive = self._leggi_attive()
|
||||
attive[sorgente] = canonico
|
||||
self._scrivi_attive(attive)
|
||||
return canonico
|
||||
|
||||
def scollega(self, sorgente: str) -> None:
|
||||
"""#91: la sorgente non ha piu' una calibrazione attiva; la libreria resta."""
|
||||
with self._lock_dati:
|
||||
attive = self._leggi_attive()
|
||||
if sorgente in attive:
|
||||
del attive[sorgente]
|
||||
self._scrivi_attive(attive)
|
||||
|
||||
def attiva_di(self, sorgente: str) -> str | None:
|
||||
"""#91: il nome della calibrazione attiva della sorgente (se esiste ancora)."""
|
||||
nome = self._leggi_attive().get(sorgente)
|
||||
dati = self.leggi_nome(nome) if nome else None
|
||||
return str(dati["nome"]) if dati else None
|
||||
|
||||
def leggi(self, sorgente: str) -> dict | None:
|
||||
"""#91: la calibrazione ATTIVA della sorgente, con "nome" e "source"; None se nessuna."""
|
||||
nome = self.attiva_di(sorgente)
|
||||
dati = self.leggi_nome(nome) if nome else None
|
||||
return {**dati, "source": sorgente} if dati else None
|
||||
|
||||
def leggi_mappa(self, sorgente: str) -> MappaPronta | None:
|
||||
"""La mappa della calibrazione attiva, pronta per `raddrizza`; None se non ne ha una."""
|
||||
nome = self.attiva_di(sorgente) # #91: la mappa e' della voce attiva, non della sorgente
|
||||
if nome is None:
|
||||
return None
|
||||
percorso = self._file_mappa_nome(nome)
|
||||
chiave = str(percorso)
|
||||
try:
|
||||
mtime = percorso.stat().st_mtime_ns
|
||||
except OSError:
|
||||
with self._lock:
|
||||
self._cache.pop(chiave, None)
|
||||
return None
|
||||
with self._lock:
|
||||
voce = self._cache.get(chiave)
|
||||
if voce is not None and voce[0] == mtime:
|
||||
return voce[1]
|
||||
try:
|
||||
pronta = _prepara_mappa(DistortionMap.load(percorso))
|
||||
except (OSError, ValueError, KeyError):
|
||||
return None # illeggibile = nessuna correzione
|
||||
with self._lock:
|
||||
self._cache[chiave] = (mtime, pronta)
|
||||
return pronta
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,511 @@
|
||||
"""Servizio camera minimo di stazione (TieMeasureFlow, #72).
|
||||
|
||||
Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
|
||||
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
||||
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
||||
GET /camera/sources -> {"sources": [{name, kind, label}]}
|
||||
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000,
|
||||
grezzo=false} -> PNG
|
||||
#84: se la sorgente ha una mappa di distorsione salvata il frame esce
|
||||
raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è.
|
||||
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
||||
|
||||
Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
|
||||
#91: archivio UNICO di calibrazioni con nome; una sorgente ne ha una attiva:
|
||||
POST /camera/calibrate multipart: source (obbligatorio solo senza image),
|
||||
square_mm, image (file, opzionale: senza, grab GREZZO dalla sorgente),
|
||||
cols/rows (opzionali). NON salva mai (il vecchio campo save e' ignorato).
|
||||
#84: SENZA cols/rows = calibrazione densa (default): una foto di
|
||||
scacchiera che riempie il campo, griglia trovata da sola, mappa di
|
||||
distorsione; mm/px e px/mm sono quelli dell'immagine RADDRIZZATA. CON
|
||||
cols/rows (angoli interni) = metodo planare di prima. Risposta: i campi
|
||||
del calcolo + overlay_png (b64), calibrated_at, origine ("camera:<src>" |
|
||||
"file:<nome>"), calcolo_id (il servizio tiene in memoria gli ultimi 8
|
||||
calcoli) e metodo ("densa"|"planare"), distorsione (bool) e, per la
|
||||
densa, residuo_rms_px, residuo_max_px, copertura_pct;
|
||||
422 scacchiera non trovata o parametri invalidi
|
||||
GET /camera/calibrazioni[?source=S] -> {calibrazioni: [{nome, origine,
|
||||
image_width, image_height, px_per_mm, mm_per_px, metodo, square_mm,
|
||||
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
|
||||
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
|
||||
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato
|
||||
DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega
|
||||
da ogni sorgente) | 404
|
||||
PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla
|
||||
sorgente e carica la calibrazione solo se la risoluzione (larghezza x
|
||||
altezza) coincide: 409 altrimenti; 404 nome ignoto; 200 come GET sotto
|
||||
GET /camera/calibration?source=<src> -> 200 la calibrazione ATTIVA {source,
|
||||
nome, px_per_mm, mm_per_px, ..., image_width, image_height,
|
||||
calibrated_at} | 404 "sorgente non calibrata" (lo legge il backend)
|
||||
DELETE /camera/calibration?source=<src> -> 204 (SCOLLEGA la calibrazione attiva,
|
||||
la libreria resta)
|
||||
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
|
||||
GET /acquisizione -> stessa pagina (#87)
|
||||
Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
|
||||
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
|
||||
timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito).
|
||||
Header: X-Raddrizzato si|no, X-Larghezza/X-Altezza (frame PIENO),
|
||||
X-Nitidezza (varianza del Laplaciano), X-Saturati-Pct (% pixel >= 250),
|
||||
X-Media (livello medio 0-255), tutti sul frame pieno in grigio.
|
||||
GET /camera/parametri?source=<src> -> {source, regolabile, parametri:
|
||||
{esposizione, esposizione_auto, guadagno, guadagno_auto: dominio}};
|
||||
dominio = GenicamSource.dominio (valore, tipo, min/max/passo o scelte).
|
||||
Esposizione in us, guadagno in dB. Sorgente senza capacita': regolabile=false.
|
||||
PUT /camera/parametri json {source, valori: {nome: valore}} -> come GET.
|
||||
Scrive prima gli *_auto, poi esposizione/guadagno (assesta=3).
|
||||
400 nome ignoto o corpo errato, 409 non regolabile, 422 camera rifiuta
|
||||
POST /camera/parametri/salva json {source} -> {source, salvati, file}
|
||||
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
|
||||
della camera nel servizio il file, se c'e', viene ricaricato.
|
||||
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json (default
|
||||
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
|
||||
|
||||
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
|
||||
raggiunge; questo servizio permette di scegliere l'host di ascolto.
|
||||
|
||||
Uso (dalla radice del repo):
|
||||
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \
|
||||
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>] \
|
||||
[--config-dir <cartella calibrazioni>]
|
||||
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
||||
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
|
||||
|
||||
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
||||
vs-camera, fastapi, uvicorn, opencv, python-multipart (rotta /camera/calibrate),
|
||||
visionsuite.calibration (vs-core).
|
||||
|
||||
AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
|
||||
camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import uuid # #91 id dei calcoli in attesa di nome
|
||||
from collections import OrderedDict
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from fastapi import Body, FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuori dall'event loop
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError
|
||||
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
|
||||
|
||||
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import calibrazione # noqa: E402
|
||||
from calibrazione_pagina import PAGINA # noqa: E402
|
||||
|
||||
_TRIGGER = ("continuo",)
|
||||
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
|
||||
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
|
||||
# #87: i quattro parametri regolabili, nell'ordine di presentazione
|
||||
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
|
||||
# #87: in scrittura prima gli automatismi (un valore manuale con auto acceso viene riscritto)
|
||||
_ORDINE_SCRITTURA = ("esposizione_auto", "guadagno_auto", "esposizione", "guadagno")
|
||||
|
||||
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
|
||||
_MESSAGGI = {
|
||||
"Camera is not open": "La camera non è aperta",
|
||||
"Camera is not grabbing": "La camera non sta acquisendo",
|
||||
"Grab cancelled (grabbing stopped)": "Acquisizione annullata (acquisizione fermata)",
|
||||
"Failed to read frame from RTSP stream": "Lettura del fotogramma dallo stream RTSP non riuscita",
|
||||
}
|
||||
_PREFISSI = (
|
||||
("Failed to open OpenCV device", "Apertura del dispositivo OpenCV non riuscita"),
|
||||
("Failed to open RTSP stream", "Apertura dello stream RTSP non riuscita"),
|
||||
("Failed to reconnect to RTSP stream", "Riconnessione allo stream RTSP non riuscita"),
|
||||
("Failed to read frame after", "Lettura del fotogramma non riuscita dopo"),
|
||||
)
|
||||
|
||||
|
||||
def _traduci(messaggio: str) -> str:
|
||||
if messaggio in _MESSAGGI:
|
||||
return _MESSAGGI[messaggio]
|
||||
for inglese, italiano in _PREFISSI:
|
||||
if messaggio.startswith(inglese):
|
||||
return italiano + messaggio[len(inglese):]
|
||||
return messaggio
|
||||
|
||||
|
||||
def _config_dir_default() -> Path:
|
||||
"""#83: cartella delle calibrazioni salvate."""
|
||||
if os.name == "nt":
|
||||
base = os.environ.get("ProgramData") or "C:/ProgramData"
|
||||
return Path(base) / "Tielogic" / "TieMeasureFlow" / "acquisitore"
|
||||
return Path("acquisitore")
|
||||
|
||||
|
||||
def create_app(folder: Path | str | None = None,
|
||||
config_dir: Path | str | None = None) -> FastAPI:
|
||||
archivio = calibrazione.Archivio(config_dir if config_dir is not None
|
||||
else _config_dir_default()) # #83
|
||||
aperte: dict = {}
|
||||
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
|
||||
lock_calcoli = threading.Lock()
|
||||
|
||||
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")]}
|
||||
|
||||
def _apri(nome: str):
|
||||
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
|
||||
with lock:
|
||||
sorgente = aperte.get(nome)
|
||||
if sorgente is None:
|
||||
sorgente = open_source(nome, None, config=_config())
|
||||
# TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda
|
||||
if hasattr(sorgente, "solo_ultimo_frame"):
|
||||
try:
|
||||
sorgente.solo_ultimo_frame()
|
||||
except Exception as e: # noqa: BLE001 - producer senza il nodo: si apre lo stesso
|
||||
_log.warning("frame piu' recente non impostabile su %s: %s", nome, e)
|
||||
_carica_settaggi(nome, sorgente) # #87: predefiniti salvati, se ci sono
|
||||
with lock:
|
||||
aperte[nome] = sorgente
|
||||
return sorgente
|
||||
|
||||
def _file_settaggi(nome: str) -> Path:
|
||||
# #87: stesso schema di nome della calibrazione, in una sottocartella
|
||||
return archivio.cartella / "settaggi" / archivio._file(nome).name
|
||||
|
||||
def _carica_settaggi(nome: str, sorgente) -> None:
|
||||
percorso = _file_settaggi(nome)
|
||||
if not percorso.is_file() or not hasattr(sorgente, "carica_settaggi"):
|
||||
return
|
||||
try:
|
||||
sorgente.carica_settaggi(percorso)
|
||||
except Exception as e: # noqa: BLE001 - un file rotto non blocca l'apertura
|
||||
_log.warning("settaggi di %s non caricati (%s): %s", nome, percorso, e)
|
||||
|
||||
def _con_sorgente(nome: str, fn):
|
||||
"""#87: fn(sorgente) sotto il lock della sorgente; errori camera -> HTTP."""
|
||||
try:
|
||||
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
|
||||
return fn(_apri(nome))
|
||||
except SourceNotFound as e:
|
||||
raise HTTPException(404, _traduci(str(e)))
|
||||
except TimeoutError as e:
|
||||
raise HTTPException(504, _traduci(str(e)) or "timeout")
|
||||
except (CameraGrabError, CameraError) as e:
|
||||
raise HTTPException(503, _traduci(str(e)))
|
||||
|
||||
def _grab(nome: str, timeout: int):
|
||||
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
|
||||
return _con_sorgente(nome, lambda s: s.grab(timeout)) # #87: corpo in _con_sorgente
|
||||
|
||||
@app.post("/camera/grab")
|
||||
def camera_grab(corpo: dict = Body(default=None)):
|
||||
nome = corpo.get("source") if isinstance(corpo, dict) else None
|
||||
if not isinstance(nome, str) or not nome.strip():
|
||||
raise HTTPException(400, "manca «source»")
|
||||
nome = nome.strip()
|
||||
trigger = corpo.get("trigger", "continuo")
|
||||
if trigger not in _TRIGGER:
|
||||
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
||||
grezzo = corpo.get("grezzo", False) # #84: true = frame non raddrizzato
|
||||
if not isinstance(grezzo, bool):
|
||||
raise HTTPException(400, "«grezzo» deve essere true o false")
|
||||
timeout = corpo.get("timeout_ms", 5000)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||
frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato
|
||||
raddrizzato = False
|
||||
if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache (#91: quella attiva)
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
if mappa is not None:
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is not None: # None = dimensioni diverse da quelle calibrate
|
||||
frame, raddrizzato = corretto, True
|
||||
ok, buf = cv2.imencode(".png", frame)
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica PNG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/png",
|
||||
headers={"X-Raddrizzato": "si" if raddrizzato else "no"})
|
||||
|
||||
# ---- #83: calibrazione a scacchiera per sorgente
|
||||
|
||||
def _sorgente(valore) -> str:
|
||||
if not isinstance(valore, str) or not valore.strip():
|
||||
raise HTTPException(400, "manca «source»")
|
||||
return valore.strip()
|
||||
|
||||
@app.get("/camera/calibration")
|
||||
def calibrazione_leggi(source: str = ""):
|
||||
dati = archivio.leggi(_sorgente(source)) # #91: la calibrazione ATTIVA della sorgente
|
||||
if dati is None:
|
||||
raise HTTPException(404, "sorgente non calibrata")
|
||||
return dati
|
||||
|
||||
@app.delete("/camera/calibration", status_code=204)
|
||||
def calibrazione_elimina(source: str = ""):
|
||||
archivio.scollega(_sorgente(source)) # #91: scollega soltanto, la libreria resta
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.post("/camera/calibrate")
|
||||
async def calibrazione_calcola(
|
||||
source: str | None = Form(None), # #91: serve solo se manca l'immagine
|
||||
square_mm: float = Form(...),
|
||||
cols: int | None = Form(None), # #84: opzionali, senza = calibrazione densa
|
||||
rows: int | None = Form(None),
|
||||
image: UploadFile | None = File(None),
|
||||
save: bool = Form(False), # #91: ignorato, il calcolo non salva mai (si salva con nome)
|
||||
):
|
||||
contenuto = await image.read() if image is not None else b""
|
||||
if contenuto:
|
||||
frame = cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
if frame is None:
|
||||
raise HTTPException(422, "l'immagine caricata non è leggibile")
|
||||
origine = "file:" + (Path((image.filename or "").replace("\\", "/")).name or "immagine")
|
||||
else:
|
||||
nome = _sorgente(source)
|
||||
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
|
||||
origine = nome
|
||||
if (cols is None) != (rows is None):
|
||||
raise HTTPException(422, "cols e rows vanno indicati insieme (o nessuno dei due)")
|
||||
mappa = None
|
||||
try:
|
||||
if cols is None: # #84: densa, nessuna griglia da dichiarare
|
||||
risultato, mappa = await run_in_threadpool(
|
||||
calibrazione.calibra_scacchiera_densa, frame, square_mm)
|
||||
else: # metodo planare di #83, invariato
|
||||
risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
|
||||
except calibrazione.CalibrazioneError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
corpo = {**{k: v for k, v in risultato.items() if k != "overlay_png"},
|
||||
"calibrated_at": calibrazione.ora_iso(), "origine": origine}
|
||||
calcolo_id = uuid.uuid4().hex
|
||||
with lock_calcoli: # #91: il calcolo resta in memoria finche' non gli si da' un nome
|
||||
calcoli[calcolo_id] = (corpo, mappa)
|
||||
while len(calcoli) > _MAX_CALCOLI:
|
||||
calcoli.popitem(last=False)
|
||||
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"]}
|
||||
|
||||
# ---- #91: archivio unico di calibrazioni con nome
|
||||
|
||||
@app.get("/camera/calibrazioni")
|
||||
def calibrazioni_elenco(source: str | None = None):
|
||||
attiva = archivio.attiva_di(_sorgente(source)) if source is not None else None
|
||||
return {"calibrazioni": archivio.elenco(), "attiva": attiva}
|
||||
|
||||
@app.post("/camera/calibrazioni", status_code=201)
|
||||
def calibrazioni_salva(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
calcolo_id = corpo.get("calcolo_id")
|
||||
if not isinstance(calcolo_id, str) or not calcolo_id:
|
||||
raise HTTPException(400, "manca «calcolo_id»")
|
||||
sovrascrivi = corpo.get("sovrascrivi", False)
|
||||
if not isinstance(sovrascrivi, bool):
|
||||
raise HTTPException(400, "«sovrascrivi» deve essere true o false")
|
||||
try:
|
||||
nome = calibrazione.pulisci_nome(corpo.get("nome"))
|
||||
except calibrazione.NomeNonValido as e:
|
||||
raise HTTPException(400, str(e))
|
||||
with lock_calcoli:
|
||||
voce = calcoli.get(calcolo_id)
|
||||
if voce is None:
|
||||
raise HTTPException(404, "calcolo non più disponibile: ricalcolare")
|
||||
dati, mappa = voce
|
||||
try:
|
||||
return archivio.salva_nome(nome, dati, mappa, sovrascrivi)
|
||||
except calibrazione.NomeGiaUsato as e:
|
||||
raise HTTPException(409, str(e))
|
||||
|
||||
@app.delete("/camera/calibrazioni", status_code=204)
|
||||
def calibrazioni_elimina(nome: str = ""):
|
||||
if not archivio.elimina_nome(nome):
|
||||
raise HTTPException(404, "calibrazione non trovata")
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.put("/camera/calibrazione-attiva")
|
||||
def calibrazione_attiva(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
sorgente = _sorgente(corpo.get("source"))
|
||||
richiesto = corpo.get("nome")
|
||||
dati = archivio.leggi_nome(richiesto) if isinstance(richiesto, str) else None
|
||||
if dati is None:
|
||||
raise HTTPException(404, "calibrazione non trovata")
|
||||
nome = dati["nome"]
|
||||
frame = _grab(sorgente, 5000) # stesso lock e stessi errori di /camera/grab (404/503/504)
|
||||
alt, larg = frame.shape[:2]
|
||||
cw, ch = dati.get("image_width"), dati.get("image_height")
|
||||
if not isinstance(cw, int) or not isinstance(ch, int):
|
||||
raise HTTPException(409, f"la calibrazione «{nome}» non ha la risoluzione registrata: "
|
||||
"non si può caricare")
|
||||
if (cw, ch) != (larg, alt): # la mappa vale solo per frame della stessa risoluzione
|
||||
raise HTTPException(409, f"la calibrazione «{nome}» è per immagini {cw}×{ch}, "
|
||||
f"la sorgente dà {larg}×{alt}: non si può caricare")
|
||||
archivio.attiva(sorgente, nome)
|
||||
return archivio.leggi(sorgente)
|
||||
|
||||
# ---- #87: acquisizione (anteprima, parametri, settaggi)
|
||||
|
||||
def _regolabile(s) -> bool:
|
||||
# #87: ha il vocabolario delle capacita' e ne supporta almeno una delle quattro
|
||||
if not (hasattr(s, "supporta") and hasattr(s, "dominio")):
|
||||
return False
|
||||
return any(s.supporta(n) for n in _PARAMETRI)
|
||||
|
||||
def _parametri(nome: str, s) -> dict:
|
||||
"""#87: forma di GET /camera/parametri; da chiamare sotto lock."""
|
||||
parametri: dict = {}
|
||||
if _regolabile(s):
|
||||
for n in _PARAMETRI:
|
||||
if not s.supporta(n):
|
||||
continue
|
||||
try:
|
||||
parametri[n] = s.dominio(n)
|
||||
except CameraStateError as e: # capacita' dichiarata ma non leggibile: si salta
|
||||
_log.warning("dominio di %s su %s non letto: %s", n, nome, e)
|
||||
return {"source": nome, "regolabile": bool(parametri), "parametri": parametri}
|
||||
|
||||
def _grigio8(frame):
|
||||
g = frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
if g.dtype == np.uint16: # #87: 16 bit -> 8 bit, per soglia e media coerenti
|
||||
g = (g >> 8).astype(np.uint8)
|
||||
elif g.dtype != np.uint8:
|
||||
g = cv2.convertScaleAbs(g, alpha=255.0 / max(float(g.max()), 1.0))
|
||||
return g
|
||||
|
||||
@app.post("/camera/anteprima")
|
||||
def camera_anteprima(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
grezzo = corpo.get("grezzo", False)
|
||||
if not isinstance(grezzo, bool):
|
||||
raise HTTPException(400, "«grezzo» deve essere true o false")
|
||||
lato = corpo.get("lato_max", 1280)
|
||||
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
|
||||
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
|
||||
timeout = corpo.get("timeout_ms", 5000)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||
frame = _grab(nome, timeout)
|
||||
raddrizzato = False
|
||||
if not grezzo: # come /camera/grab
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
if mappa is not None:
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is not None:
|
||||
frame, raddrizzato = corretto, True
|
||||
alt, larg = frame.shape[:2]
|
||||
grigio = _grigio8(frame) # misure sul frame PIENO, non sul ridotto
|
||||
nitidezza = float(cv2.Laplacian(grigio, cv2.CV_64F).var())
|
||||
saturati = float((grigio >= 250).mean() * 100.0)
|
||||
media = float(grigio.mean())
|
||||
if frame.dtype != np.uint8: # il JPEG vuole 8 bit
|
||||
frame = grigio
|
||||
scala = lato / max(alt, larg)
|
||||
if scala < 1.0: # mai ingrandita
|
||||
frame = cv2.resize(frame, (max(1, round(larg * scala)), max(1, round(alt * scala))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica JPEG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/jpeg", headers={
|
||||
"X-Raddrizzato": "si" if raddrizzato else "no",
|
||||
"X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
|
||||
"X-Media": f"{media:.1f}"})
|
||||
|
||||
@app.get("/camera/parametri")
|
||||
def parametri_leggi(source: str = ""):
|
||||
nome = _sorgente(source)
|
||||
return _con_sorgente(nome, lambda s: _parametri(nome, s))
|
||||
|
||||
@app.put("/camera/parametri")
|
||||
def parametri_scrivi(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
valori = corpo.get("valori")
|
||||
if not isinstance(valori, dict) or not valori:
|
||||
raise HTTPException(400, "manca «valori»")
|
||||
for n, v in valori.items():
|
||||
if n not in _PARAMETRI:
|
||||
raise HTTPException(400, f"parametro non riconosciuto: «{n}»")
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
|
||||
raise HTTPException(400, f"valore non valido per «{n}»")
|
||||
|
||||
def scrivi(s):
|
||||
if not _regolabile(s):
|
||||
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
|
||||
for n in _ORDINE_SCRITTURA: # prima gli *_auto
|
||||
if n in valori:
|
||||
try:
|
||||
s.scrivi(n, valori[n], assesta=3)
|
||||
except CameraStateError as e:
|
||||
raise HTTPException(422, _traduci(str(e)))
|
||||
return _parametri(nome, s) # valori riletti dalla camera
|
||||
|
||||
return _con_sorgente(nome, scrivi)
|
||||
|
||||
@app.post("/camera/parametri/salva")
|
||||
def parametri_salva(corpo: dict = Body(default=None)):
|
||||
nome = _sorgente(corpo.get("source") if isinstance(corpo, dict) else None)
|
||||
|
||||
def salva(s):
|
||||
if not _regolabile(s) or not hasattr(s, "salva_settaggi"):
|
||||
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
|
||||
percorso = _file_settaggi(nome)
|
||||
percorso.parent.mkdir(parents=True, exist_ok=True)
|
||||
s.salva_settaggi(percorso)
|
||||
return {"source": nome, "salvati": True, "file": percorso.name}
|
||||
|
||||
return _con_sorgente(nome, salva)
|
||||
|
||||
@app.get("/calibrazione", response_class=HTMLResponse)
|
||||
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
|
||||
def calibrazione_pagina():
|
||||
return HTMLResponse(PAGINA)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
p = argparse.ArgumentParser(description="Servizio camera minimo di stazione (#72)")
|
||||
p.add_argument("--host", default="127.0.0.1")
|
||||
p.add_argument("--port", type=int, default=8765)
|
||||
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder")
|
||||
p.add_argument("--config-dir", default=None,
|
||||
help="cartella delle calibrazioni salvate (default: ProgramData su Windows)") # #83
|
||||
a = p.parse_args()
|
||||
uvicorn.run(create_app(a.folder, a.config_dir), host=a.host, port=a.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+90
-11
@@ -21,16 +21,28 @@ are already in place:
|
||||
|
||||
Re-running finds the recipes already there and stops. --replace deletes them
|
||||
first, along with anything measured against them - which is what you want between
|
||||
one acceptance session and the next, and never what you want by accident.
|
||||
one acceptance session and the next, and never what you want by accident: it
|
||||
prints how many Measurement/ProductionRun/ProductionEvent rows that is and
|
||||
refuses to touch them without --si.
|
||||
|
||||
Refuses to run against a real deployment (APP_ENV outside dev/test, same rule
|
||||
as src.backend.config) unless --conferma-produzione is also given: this script
|
||||
deletes data, and a shop floor is not where to find that out by mistake.
|
||||
|
||||
If --supervisor-password is not given and the capoturno account does not exist
|
||||
yet, a random one is generated and printed once - never committed, never a
|
||||
default anyone could look up.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
from src.backend.config import settings # noqa: E402
|
||||
from src.backend.database import async_session_factory # noqa: E402
|
||||
from src.backend.models.orm.measurement import Measurement # noqa: E402
|
||||
from src.backend.models.orm.production import ( # noqa: E402
|
||||
@@ -86,6 +98,43 @@ async def _find_recipes(session, codes):
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _count_replace_impact(session, recipes) -> dict:
|
||||
"""How many Measurement/ProductionRun/ProductionEvent --replace would delete.
|
||||
|
||||
Read-only mirror of what _delete_recipes actually removes, so the count
|
||||
printed before the confirmation matches the count deleted after it.
|
||||
"""
|
||||
version_ids = []
|
||||
run_ids = []
|
||||
for recipe in recipes:
|
||||
version_ids += (await session.execute(
|
||||
select(RecipeVersion.id).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().all()
|
||||
run_ids += (await session.execute(
|
||||
select(ProductionRun.id).where(ProductionRun.recipe_id == recipe.id)
|
||||
)).scalars().all()
|
||||
|
||||
measurements = 0
|
||||
if version_ids:
|
||||
measurements = (await session.execute(
|
||||
select(func.count()).select_from(Measurement)
|
||||
.where(Measurement.version_id.in_(version_ids))
|
||||
)).scalar_one()
|
||||
|
||||
events = 0
|
||||
if run_ids:
|
||||
events = (await session.execute(
|
||||
select(func.count()).select_from(ProductionEvent)
|
||||
.where(ProductionEvent.run_id.in_(run_ids))
|
||||
)).scalar_one()
|
||||
|
||||
return {
|
||||
"measurements": measurements,
|
||||
"production_runs": len(run_ids),
|
||||
"production_events": events,
|
||||
}
|
||||
|
||||
|
||||
async def _delete_recipes(session, recipes):
|
||||
"""Remove the collaudo recipes and everything measured against them."""
|
||||
for recipe in recipes:
|
||||
@@ -176,25 +225,33 @@ async def _ensure_supervisor(session, username, password):
|
||||
|
||||
An admin is accepted by the authorisation check, but the acceptance session
|
||||
should exercise the role the shop floor will actually use.
|
||||
|
||||
`password` may be None: it is only needed when the account does not exist
|
||||
yet, in which case a random one is generated here rather than falling
|
||||
back to a committed default. Returns (user, created, password_used) so
|
||||
the caller can print the generated one - the only place it appears.
|
||||
"""
|
||||
existing = (await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
return existing, False, password
|
||||
|
||||
password_used = password or secrets.token_urlsafe(12)
|
||||
user = await auth_service.create_user(
|
||||
session,
|
||||
username=username,
|
||||
password=password,
|
||||
password=password_used,
|
||||
display_name="Capo Turno (collaudo)",
|
||||
roles=["Supervisor", "MeasurementTec"],
|
||||
)
|
||||
await session.flush()
|
||||
return user, True
|
||||
return user, True, password_used
|
||||
|
||||
|
||||
async def seed(station_code: str, replace: bool, supervisor_password: str) -> int:
|
||||
async def seed(
|
||||
station_code: str, replace: bool, supervisor_password: str | None, si: bool,
|
||||
) -> int:
|
||||
async with async_session_factory() as session:
|
||||
owner = (await session.execute(
|
||||
select(User).where(User.is_admin == True) # noqa: E712
|
||||
@@ -217,6 +274,14 @@ async def seed(station_code: str, replace: bool, supervisor_password: str) -> in
|
||||
print("Usa --replace per rifarle da zero (cancella anche le misure).")
|
||||
return 1
|
||||
if existing:
|
||||
impact = await _count_replace_impact(session, existing)
|
||||
print(f"--replace cancellera' ({', '.join(r.code for r in existing)}):")
|
||||
print(f" {impact['measurements']} misure")
|
||||
print(f" {impact['production_runs']} produzioni")
|
||||
print(f" {impact['production_events']} eventi di produzione")
|
||||
if not si:
|
||||
print("Nessuna cancellazione eseguita. Aggiungi --si per confermare.")
|
||||
return 1
|
||||
await _delete_recipes(session, existing)
|
||||
print(f"Rimosse: {', '.join(r.code for r in existing)}")
|
||||
|
||||
@@ -286,7 +351,7 @@ async def seed(station_code: str, replace: bool, supervisor_password: str) -> in
|
||||
)
|
||||
await _assign(session, station, recipe_b, owner.id)
|
||||
|
||||
supervisor, created = await _ensure_supervisor(
|
||||
supervisor, created, password_used = await _ensure_supervisor(
|
||||
session, "capoturno", supervisor_password,
|
||||
)
|
||||
|
||||
@@ -296,7 +361,7 @@ async def seed(station_code: str, replace: bool, supervisor_password: str) -> in
|
||||
print(" COLLAUDO-A ciclo di misura, lotto obbligatorio, intervallo 2 min")
|
||||
print(" COLLAUDO-B solo calibro, lotto e seriale obbligatori")
|
||||
if created:
|
||||
print(f"Capoturno creato: {supervisor.username} / {supervisor_password}")
|
||||
print(f"Capoturno creato: {supervisor.username} / {password_used}")
|
||||
print(" cambiare la password prima di qualunque uso reale.")
|
||||
else:
|
||||
print(f"Capoturno gia' presente: {supervisor.username} (password invariata)")
|
||||
@@ -309,11 +374,25 @@ def main() -> int:
|
||||
help="codice della stazione a cui assegnare le ricette")
|
||||
parser.add_argument("--replace", action="store_true",
|
||||
help="cancella le ricette di collaudo esistenti e le misure fatte su di esse")
|
||||
parser.add_argument("--supervisor-password", default="Collaudo2026!",
|
||||
help="password del capoturno, se va creato")
|
||||
parser.add_argument("--si", action="store_true",
|
||||
help="conferma la cancellazione di --replace (senza, si limita a mostrarla)")
|
||||
parser.add_argument("--supervisor-password", default=None,
|
||||
help="password del capoturno, se va creato; "
|
||||
"se omessa ne viene generata una casuale e stampata una volta")
|
||||
parser.add_argument("--conferma-produzione", action="store_true",
|
||||
help="richiesto per lanciare lo script con APP_ENV di produzione")
|
||||
args = parser.parse_args()
|
||||
|
||||
if settings.app_env not in ("dev", "test") and not args.conferma_produzione:
|
||||
print(
|
||||
f"APP_ENV={settings.app_env!r} indica una produzione: "
|
||||
"questo script scrive ricette e utenti di collaudo. "
|
||||
"Rilancia con --conferma-produzione se e' davvero quello che vuoi."
|
||||
)
|
||||
return 2
|
||||
|
||||
return asyncio.run(
|
||||
seed(args.station, args.replace, args.supervisor_password)
|
||||
seed(args.station, args.replace, args.supervisor_password, args.si)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Seed per il PC della fiera: utenti, stazione, tre ricette e quattro settimane di misure.
|
||||
|
||||
Cosa fa
|
||||
Popola un database TieMeasureFlow (SQLite del pacchetto Windows) cosi' che al primo
|
||||
avvio NON compaia la procedura di setup e la demo abbia gia' qualcosa da mostrare:
|
||||
|
||||
- utenti adriano (admin), maker, capoturno, operatore (password nella costante
|
||||
UTENTI qui sotto, decise da Adriano per la fiera);
|
||||
- la stazione ST-DEFAULT (quella del pacchetto, vedi packaging/windows/avvio.py)
|
||||
con camera_url http://127.0.0.1:8765 e camera_source camera:601020 (Itala);
|
||||
- FIERA-FLANGIA «Flangia Ø80» (5 quote) e FIERA-ALBERO «Albero rettificato»
|
||||
(4 quote), con circa 4 settimane di storico fino a ieri (lun-ven 8-17, una
|
||||
produzione chiusa per giorno, lotti e seriali coerenti, misure con Cpk diversi
|
||||
per quota, una deriva lenta nell'ultima settimana, una quota con 1-3% fuori
|
||||
tolleranza);
|
||||
- FIERA-DXF «Profilo DXF (visione)»: un task dxf_compare senza ricetta di visione
|
||||
(si crea in fiera dal pannello «Crea ricetta da DXF»).
|
||||
|
||||
«Setup fatto» per il backend vuol dire: esiste almeno un utente admin
|
||||
(api/routers/setup.py::_admin_exists), quindi basta creare adriano.
|
||||
|
||||
Come si usa (da daitarn, TMF fermo sul PC, su una COPIA del file SQLite)
|
||||
uv run python scripts/seed_fiera.py --db C:/percorso/tmf.db [--replace] [--dry-run]
|
||||
[--fine AAAA-MM-GG]
|
||||
|
||||
Il file deve avere gia' lo schema (alembic upgrade head: lo fa l'exe al primo
|
||||
avvio). --replace toglie solo i dati FIERA-* (ricette, misure, produzioni) e li
|
||||
rifa'; senza, un secondo giro non duplica le ricette. Gli utenti e la stazione
|
||||
sono idempotenti: se esistono si aggiornano password, ruoli e camera.
|
||||
--dry-run fa tutto e annulla (stampa i numeri). Il seme casuale e' fisso: stesso
|
||||
comando e stessa data di fine, stessi dati.
|
||||
|
||||
Da cosa dipende
|
||||
src.backend (modelli ORM, auth_service, measurement_service), SQLAlchemy +
|
||||
aiosqlite, tzdata su Windows (gia' in pyproject). Non usa MySQL.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import random
|
||||
import sys
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, event, select
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession, async_sessionmaker, create_async_engine,
|
||||
)
|
||||
|
||||
# La radice del repo nel path, cosi' "src.backend" si importa lanciando lo script da ovunque
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from src.backend.database import async_session_factory # noqa: E402
|
||||
from src.backend.models.orm.measurement import Measurement # noqa: E402
|
||||
from src.backend.models.orm.production import ( # noqa: E402
|
||||
ProductionEvent, ProductionRun,
|
||||
)
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
|
||||
from src.backend.models.orm.setting import RecipeVersionAudit # noqa: E402
|
||||
from src.backend.models.orm.station import ( # noqa: E402
|
||||
Station, StationRecipeAssignment,
|
||||
)
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
|
||||
from src.backend.models.orm.task_result import TaskResult # noqa: E402
|
||||
from src.backend.models.orm.user import User # noqa: E402
|
||||
from src.backend.services import auth_service # noqa: E402
|
||||
from src.backend.services.measurement_service import calculate_pass_fail # noqa: E402
|
||||
|
||||
CODES = ("FIERA-FLANGIA", "FIERA-ALBERO", "FIERA-DXF")
|
||||
STATION_CODE = "ST-DEFAULT" # STATION_CODE del pacchetto Windows (avvio.py PREDEFINITI)
|
||||
CAMERA_URL = "http://127.0.0.1:8765"
|
||||
CAMERA_SOURCE = "camera:601020" # Itala sul PC fiera
|
||||
SEED = 20261026 # seme fisso: dati ripetibili
|
||||
FUSO = ZoneInfo("Europe/Rome") # le misure sono salvate in UTC naive, l'orario di turno e' locale
|
||||
GIORNI_STORICO = 28 # circa 4 settimane
|
||||
|
||||
# (username, password, nome, ruoli, admin). Password "123" per tutti, chiesta da Adriano per la fiera (#85); il login non controlla la lunghezza.
|
||||
UTENTI = [
|
||||
("adriano", "123", "Adriano Dal Pastro",
|
||||
["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True),
|
||||
("maker", "123", "Marco Ferrari (Maker)", ["Maker"], False),
|
||||
("capoturno", "123", "Luca Bianchi (Capoturno)",
|
||||
["Supervisor", "MeasurementTec"], False),
|
||||
("operatore", "123", "Giulia Rossi (Operatore)", ["MeasurementTec"], False),
|
||||
]
|
||||
|
||||
|
||||
def _quota(marker, descrizione, nominale, tol, unita="mm", dec=3):
|
||||
"""Quota simmetrica: tolleranza = nominale +/- tol, warning al 70% della tolleranza."""
|
||||
return dict(
|
||||
marker_number=marker, description=descrizione, nominal=nominale,
|
||||
ltl=round(nominale - tol, dec), lwl=round(nominale - 0.7 * tol, dec),
|
||||
uwl=round(nominale + 0.7 * tol, dec), utl=round(nominale + tol, dec),
|
||||
unit=unita,
|
||||
)
|
||||
|
||||
|
||||
# Profilo statistico di una quota: scostamento medio (in frazioni di tol), sigma (in
|
||||
# frazioni di tol) e deriva (frazione di tol aggiunta linearmente nell'ultima settimana).
|
||||
# Cpk ~ (1 - |scostamento|) / (3 * sigma): sigma 0.2 -> 1,67; 0.33 -> 1,0.
|
||||
FLANGIA = [
|
||||
# (task, quota, scostamento, sigma, deriva)
|
||||
(0, _quota(1, "Diametro esterno Ø80", 80.0, 0.05), 0.0, 0.20, 0.0), # Cpk ~1,67 capace
|
||||
(0, _quota(2, "Diametro foro centrale Ø25", 25.0, 0.02), 0.0, 0.33, 0.0), # Cpk ~1,0 al limite
|
||||
(1, _quota(3, "Spessore flangia", 12.0, 0.10), 0.0, 0.22, 0.55), # deriva ultima settimana
|
||||
(1, _quota(4, "Interasse fori Ø60", 60.0, 0.10), 0.10, 0.25, 0.0), # Cpk ~1,2
|
||||
(1, _quota(5, "Profondita' lamatura", 8.0, 0.15), 0.50, 0.244, 0.0), # ~2% fuori tolleranza
|
||||
]
|
||||
ALBERO = [
|
||||
(0, _quota(1, "Diametro cuscinetto Ø20 h6", 20.0, 0.010), 0.0, 0.20, 0.0), # Cpk ~1,67
|
||||
(0, _quota(2, "Diametro sede Ø25 j5", 25.0, 0.008), 0.0, 0.25, 0.0), # Cpk ~1,33
|
||||
(1, _quota(3, "Lunghezza totale", 150.0, 0.10), 0.0, 0.33, 0.0), # Cpk ~1,0
|
||||
(1, _quota(4, "Rugosita' Ra", 0.40, 0.20, unita="µm"), 0.20, 0.25, 0.0), # Cpk ~1,07
|
||||
]
|
||||
|
||||
|
||||
def _ora_utc(giorno: date, minuti_da_mezzanotte: int) -> datetime:
|
||||
"""Ora locale di officina -> datetime UTC naive, come le salva il backend."""
|
||||
locale = datetime.combine(giorno, time(0, 0), tzinfo=FUSO) + timedelta(minutes=minuti_da_mezzanotte)
|
||||
return locale.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _giorni_lavorativi(fine: date) -> list[date]:
|
||||
"""Lun-ven degli ultimi GIORNI_STORICO giorni, fino a `fine` compreso."""
|
||||
giorni = [fine - timedelta(days=i) for i in range(GIORNI_STORICO)]
|
||||
return sorted(g for g in giorni if g.weekday() < 5)
|
||||
|
||||
|
||||
def _slot_giornalieri(rng: random.Random, n: int) -> list[int]:
|
||||
"""n orari (minuti dalla mezzanotte) in 8-12 e 13-17, ogni ~30 min con un po' di scarto."""
|
||||
mezz_ore = [8 * 60 + 30 * i for i in range(8)] + [13 * 60 + 30 * i for i in range(8)]
|
||||
scelti = sorted(rng.sample(mezz_ore, n))
|
||||
return [m + rng.randint(0, 9) for m in scelti]
|
||||
|
||||
|
||||
async def _upsert_utenti(session: AsyncSession) -> dict[str, User]:
|
||||
"""Crea gli utenti o, se esistono, ne aggiorna password, nome, ruoli e flag admin."""
|
||||
utenti = {}
|
||||
for username, password, nome, ruoli, admin in UTENTI:
|
||||
esistente = (await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)).scalar_one_or_none()
|
||||
if esistente is None:
|
||||
utenti[username] = await auth_service.create_user(
|
||||
session, username=username, password=password, display_name=nome,
|
||||
roles=ruoli, is_admin=admin,
|
||||
)
|
||||
else:
|
||||
esistente.password_hash = auth_service.hash_password(password)
|
||||
esistente.display_name = nome
|
||||
esistente.roles = ruoli
|
||||
esistente.is_admin = admin
|
||||
esistente.active = True
|
||||
utenti[username] = esistente
|
||||
await session.flush()
|
||||
return utenti
|
||||
|
||||
|
||||
async def _upsert_stazione(session: AsyncSession, owner: User) -> Station:
|
||||
"""La stazione del pacchetto, con la camera Itala; se c'e' gia' ne aggiorna la camera."""
|
||||
stazione = (await session.execute(
|
||||
select(Station).where(Station.code == STATION_CODE)
|
||||
)).scalar_one_or_none()
|
||||
if stazione is None:
|
||||
stazione = Station(
|
||||
code=STATION_CODE, name="Postazione fiera", location="Stand",
|
||||
created_by=owner.id,
|
||||
)
|
||||
session.add(stazione)
|
||||
stazione.camera_url = CAMERA_URL
|
||||
stazione.camera_source = CAMERA_SOURCE
|
||||
await session.flush()
|
||||
return stazione
|
||||
|
||||
|
||||
async def _conta_e_cancella(session: AsyncSession, ricette: list[Recipe]) -> dict:
|
||||
"""Toglie le ricette FIERA-* e tutto cio' che e' misurato contro di esse. Ritorna i conteggi."""
|
||||
conti = {"misure": 0, "produzioni": 0, "eventi": 0}
|
||||
for ricetta in ricette:
|
||||
versioni = (await session.execute(
|
||||
select(RecipeVersion.id).where(RecipeVersion.recipe_id == ricetta.id)
|
||||
)).scalars().all()
|
||||
runs = (await session.execute(
|
||||
select(ProductionRun.id).where(ProductionRun.recipe_id == ricetta.id)
|
||||
)).scalars().all()
|
||||
if versioni:
|
||||
conti["misure"] += (await session.execute(
|
||||
delete(Measurement).where(Measurement.version_id.in_(versioni))
|
||||
)).rowcount
|
||||
await session.execute( # esiti dei task di queste versioni (FK sulle versioni)
|
||||
delete(TaskResult).where(TaskResult.version_id.in_(versioni))
|
||||
)
|
||||
if runs:
|
||||
conti["eventi"] += (await session.execute(
|
||||
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
|
||||
)).rowcount
|
||||
conti["produzioni"] += (await session.execute(
|
||||
delete(ProductionRun).where(ProductionRun.id.in_(runs))
|
||||
)).rowcount
|
||||
await session.execute(
|
||||
delete(StationRecipeAssignment).where(StationRecipeAssignment.recipe_id == ricetta.id)
|
||||
)
|
||||
await session.execute(
|
||||
delete(RecipeVersionAudit).where(RecipeVersionAudit.recipe_id == ricetta.id)
|
||||
)
|
||||
# Versioni, task e quote se ne vanno con la cascade ORM della ricetta.
|
||||
await session.delete(ricetta)
|
||||
await session.flush()
|
||||
return conti
|
||||
|
||||
|
||||
async def _nuova_ricetta(session, owner, stazione, *, code, name, description,
|
||||
interval, requires_serial, note_versione):
|
||||
"""Ricetta + versione 1 corrente + assegnazione alla stazione."""
|
||||
ricetta = Recipe(
|
||||
code=code, name=name, description=description, created_by=owner.id, active=True,
|
||||
measurement_interval_minutes=interval, requires_lot=True,
|
||||
requires_serial=requires_serial, allow_manual_input=True,
|
||||
)
|
||||
session.add(ricetta)
|
||||
await session.flush()
|
||||
versione = RecipeVersion(
|
||||
recipe_id=ricetta.id, version_number=1, is_current=True,
|
||||
created_by=owner.id, change_notes=note_versione,
|
||||
)
|
||||
session.add(versione)
|
||||
session.add(StationRecipeAssignment(
|
||||
station_id=stazione.id, recipe_id=ricetta.id, assigned_by=owner.id,
|
||||
))
|
||||
await session.flush()
|
||||
return ricetta, versione
|
||||
|
||||
|
||||
async def _task(session, versione, ordine, titolo, tipo, direttiva=None,
|
||||
descrizione=None, quote=()):
|
||||
task = RecipeTask(
|
||||
version_id=versione.id, order_index=ordine, title=titolo, task_type=tipo,
|
||||
directive=direttiva, description=descrizione,
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
sottotask = []
|
||||
for q in quote:
|
||||
st = RecipeSubtask(task_id=task.id, **q)
|
||||
session.add(st)
|
||||
sottotask.append(st)
|
||||
await session.flush()
|
||||
return task, sottotask
|
||||
|
||||
|
||||
async def _ricetta_meccanica(session, owner, stazione, *, code, name, description,
|
||||
interval, requires_serial, profilo, titoli_task):
|
||||
"""Ricetta con una nota iniziale e un task di misura per ogni gruppo di quote del profilo."""
|
||||
ricetta, versione = await _nuova_ricetta(
|
||||
session, owner, stazione, code=code, name=name, description=description,
|
||||
interval=interval, requires_serial=requires_serial,
|
||||
note_versione="Ricetta dimostrativa per la fiera",
|
||||
)
|
||||
await _task(session, versione, 0, "Preparazione del pezzo", "note",
|
||||
direttiva="Prima di misurare",
|
||||
descrizione="Pulire il pezzo e controllare che sia a **temperatura ambiente**.")
|
||||
sottotask_per_quota = {}
|
||||
for indice, titolo in enumerate(titoli_task):
|
||||
quote = [q for (t, q, *_r) in profilo if t == indice]
|
||||
_t, sottotask = await _task(
|
||||
session, versione, indice + 1, titolo, "measure",
|
||||
direttiva="Misurare le quote in sequenza", quote=quote,
|
||||
)
|
||||
for st in sottotask:
|
||||
sottotask_per_quota[st.marker_number] = st
|
||||
return ricetta, versione, sottotask_per_quota
|
||||
|
||||
|
||||
def _valore(rng, quota, scost, sigma, deriva, frazione_tempo):
|
||||
"""Un valore: normale attorno a nominale + scostamento (+ deriva nell'ultima settimana)."""
|
||||
tol = quota["utl"] - quota["nominal"]
|
||||
media = quota["nominal"] + tol * (scost + deriva * max(0.0, (frazione_tempo - 0.75) / 0.25))
|
||||
return round(rng.gauss(media, tol * sigma), 6)
|
||||
|
||||
|
||||
async def _storico(session, rng, fine, owner_ricetta, stazione, utenti, ricetta, versione,
|
||||
sottotask, profilo, pezzi_giorno, prefisso_lotto, prefisso_seriale):
|
||||
"""Una produzione chiusa per giorno lavorativo, con i suoi pezzi, eventi e misure."""
|
||||
giorni = _giorni_lavorativi(fine)
|
||||
operatore, capoturno = utenti["operatore"], utenti["capoturno"]
|
||||
task_ids = {} # marker -> id del task di misura
|
||||
for marker, st in sottotask.items():
|
||||
task_ids[marker] = st.task_id
|
||||
ordine_task = sorted(set(task_ids.values()))
|
||||
ultimo_task = ordine_task[-1]
|
||||
contatore = 0
|
||||
totale = 0
|
||||
for indice_giorno, giorno in enumerate(giorni):
|
||||
frazione = indice_giorno / max(1, len(giorni) - 1) # 0 = primo giorno, 1 = ieri
|
||||
n = rng.randint(*pezzi_giorno)
|
||||
slot = _slot_giornalieri(rng, n)
|
||||
lotto = f"{prefisso_lotto}{giorno:%y%m%d}"
|
||||
inizio, fine_run = _ora_utc(giorno, slot[0] - 5), _ora_utc(giorno, slot[-1] + 10)
|
||||
run = ProductionRun(
|
||||
station_id=stazione.id, recipe_id=ricetta.id, version_id=versione.id,
|
||||
operator_id=operatore.id, lot_number=lotto, status="closed",
|
||||
measurement_interval_minutes=ricetta.measurement_interval_minutes,
|
||||
cycle_count=n, started_at=inizio, closed_at=fine_run, closed_by=capoturno.id,
|
||||
active_station_id=None,
|
||||
)
|
||||
session.add(run)
|
||||
await session.flush()
|
||||
session.add(ProductionEvent(run_id=run.id, event_type="start", user_id=operatore.id,
|
||||
created_at=inizio))
|
||||
for numero, minuti in enumerate(slot, start=1):
|
||||
contatore += 1
|
||||
seriale = f"{prefisso_seriale}{contatore:05d}"
|
||||
istante = _ora_utc(giorno, minuti)
|
||||
for marker in sorted(sottotask):
|
||||
st = sottotask[marker]
|
||||
_t, quota, scost, sigma, deriva = next(p for p in profilo if p[1]["marker_number"] == marker)
|
||||
valore = _valore(rng, quota, scost, sigma, deriva, frazione)
|
||||
esito, scarto = calculate_pass_fail(valore, st)
|
||||
m = Measurement(
|
||||
subtask_id=st.id, version_id=versione.id, measured_by=operatore.id,
|
||||
value=valore, pass_fail=esito, deviation=scarto, lot_number=lotto,
|
||||
serial_number=seriale,
|
||||
input_method="usb_caliper" if rng.random() < 0.75 else "manual",
|
||||
input_duration_ms=rng.randint(2500, 9000),
|
||||
production_run_id=run.id,
|
||||
# un fuori tolleranza passa solo con l'ok del capoturno (come in produzione)
|
||||
supervisor_id=capoturno.id if esito == "fail" else None,
|
||||
authorised_at=istante + timedelta(minutes=1) if esito == "fail" else None,
|
||||
measured_at=istante + timedelta(seconds=20 * marker),
|
||||
)
|
||||
session.add(m)
|
||||
totale += 1
|
||||
# un task_measured per ogni task di misura non finale, poi il ciclo completato
|
||||
for tid in ordine_task:
|
||||
tipo = "cycle_completed" if tid == ultimo_task else "task_measured"
|
||||
session.add(ProductionEvent(
|
||||
run_id=run.id, event_type=tipo, user_id=operatore.id, task_id=tid,
|
||||
created_at=istante + timedelta(minutes=1),
|
||||
))
|
||||
session.add(ProductionEvent(run_id=run.id, event_type="close", user_id=capoturno.id,
|
||||
supervisor_id=capoturno.id, created_at=fine_run))
|
||||
await session.flush()
|
||||
return contatore, totale
|
||||
|
||||
|
||||
async def _ricetta_dxf(session, owner, stazione):
|
||||
"""Demo visione: un task dxf_compare, senza grafo (si crea in fiera dal pannello DXF)."""
|
||||
ricetta, versione = await _nuova_ricetta(
|
||||
session, owner, stazione, code="FIERA-DXF", name="Profilo DXF (visione)",
|
||||
description="Confronto di un profilo con il disegno DXF tramite la camera. "
|
||||
"La ricetta di visione si crea in fiera dal pannello «Crea ricetta da DXF».",
|
||||
interval=None, requires_serial=False, note_versione="Ricetta dimostrativa per la fiera",
|
||||
)
|
||||
await _task(session, versione, 0, "Preparazione del pezzo", "note",
|
||||
direttiva="Prima di misurare",
|
||||
descrizione="Appoggiare il pezzo sul piano della camera, **in piano**.")
|
||||
await _task(
|
||||
session, versione, 1, "Confronto profilo con DXF", "dxf_compare",
|
||||
direttiva="Acquisire e confrontare",
|
||||
descrizione="Lo scostamento massimo del profilo rispetto al DXF deve restare entro la tolleranza.",
|
||||
quote=[_quota(1, "Scostamento massimo profilo", 0.0, 0.15)],
|
||||
)
|
||||
return ricetta
|
||||
|
||||
|
||||
async def seed(replace: bool, dry_run: bool, fine: date) -> int:
|
||||
"""Esegue il seed in una sola transazione; con dry_run annulla tutto alla fine."""
|
||||
rng = random.Random(SEED)
|
||||
async with async_session_factory() as session:
|
||||
utenti = await _upsert_utenti(session)
|
||||
owner = utenti["adriano"]
|
||||
stazione = await _upsert_stazione(session, owner)
|
||||
|
||||
esistenti = list((await session.execute(
|
||||
select(Recipe).where(Recipe.code.in_(CODES))
|
||||
)).scalars().all())
|
||||
if esistenti and not replace:
|
||||
print(f"Ricette FIERA gia' presenti ({', '.join(r.code for r in esistenti)}): "
|
||||
"non duplico. Usa --replace per rifarle.")
|
||||
else:
|
||||
if esistenti:
|
||||
conti = await _conta_e_cancella(session, esistenti)
|
||||
print(f"--replace: rimosse {', '.join(r.code for r in esistenti)} "
|
||||
f"({conti['misure']} misure, {conti['produzioni']} produzioni, "
|
||||
f"{conti['eventi']} eventi)")
|
||||
|
||||
r1, v1, sub1 = await _ricetta_meccanica(
|
||||
session, owner, stazione, code="FIERA-FLANGIA", name="Flangia Ø80",
|
||||
description="Flangia tornita in alluminio: diametri, spessore, interasse fori "
|
||||
"e profondita' della lamatura. Lotto e seriale obbligatori.",
|
||||
interval=30, requires_serial=True, profilo=FLANGIA,
|
||||
titoli_task=["Misure diametrali", "Spessore e posizione fori"],
|
||||
)
|
||||
n1, m1 = await _storico(session, rng, fine, owner, stazione, utenti, r1, v1, sub1,
|
||||
FLANGIA, (10, 14), "LF", "FL")
|
||||
r2, v2, sub2 = await _ricetta_meccanica(
|
||||
session, owner, stazione, code="FIERA-ALBERO", name="Albero rettificato",
|
||||
description="Albero in acciaio rettificato: sedi dei cuscinetti, lunghezza e "
|
||||
"rugosita'. Tolleranze strette.",
|
||||
interval=30, requires_serial=False, profilo=ALBERO,
|
||||
titoli_task=["Diametri rettificati", "Lunghezza e rugosita'"],
|
||||
)
|
||||
n2, m2 = await _storico(session, rng, fine, owner, stazione, utenti, r2, v2, sub2,
|
||||
ALBERO, (8, 11), "LA", "AL")
|
||||
await _ricetta_dxf(session, owner, stazione)
|
||||
print(f"FIERA-FLANGIA: {n1} pezzi, {m1} misure; FIERA-ALBERO: {n2} pezzi, {m2} misure")
|
||||
|
||||
if dry_run:
|
||||
await session.rollback()
|
||||
print("--dry-run: nessuna modifica salvata.")
|
||||
else:
|
||||
await session.commit()
|
||||
print(f"Seed fiera completato (stazione {stazione.code}, utenti: "
|
||||
f"{', '.join(u[0] for u in UTENTI)}).")
|
||||
return 0
|
||||
|
||||
|
||||
def _factory_per_file(db: Path):
|
||||
"""Sessioni async su un file SQLite qualsiasi, con le stesse PRAGMA dell'app (FK attive)."""
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db.resolve().as_posix()}", echo=False)
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _pragma(dbapi_connection, _record) -> None:
|
||||
cur = dbapi_connection.cursor()
|
||||
cur.execute("PRAGMA foreign_keys=ON")
|
||||
cur.execute("PRAGMA busy_timeout=5000")
|
||||
cur.close()
|
||||
|
||||
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def _main_async(args) -> int:
|
||||
global async_session_factory
|
||||
engine, async_session_factory = _factory_per_file(args.db)
|
||||
try:
|
||||
fine = date.fromisoformat(args.fine) if args.fine else date.today() - timedelta(days=1)
|
||||
return await seed(args.replace, args.dry_run, fine)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--db", required=True, type=Path, help="file SQLite con lo schema gia' creato")
|
||||
parser.add_argument("--replace", action="store_true",
|
||||
help="rimuove i dati FIERA-* (ricette, misure, produzioni) e li rifa'")
|
||||
parser.add_argument("--dry-run", action="store_true", help="esegue tutto e annulla")
|
||||
parser.add_argument("--fine", default=None, help="ultimo giorno dello storico AAAA-MM-GG (default: ieri)")
|
||||
args = parser.parse_args()
|
||||
if not args.db.is_file():
|
||||
print(f"File non trovato: {args.db}")
|
||||
return 2
|
||||
return asyncio.run(_main_async(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,422 @@
|
||||
"""Test della calibrazione dell'acquisitore (#83): logica pura e rotte di camera_service.
|
||||
|
||||
Uso: dalla radice, `uv run --python 3.13 --extra vision-worker --extra dev
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q`
|
||||
(o il venv di VisionSuite come per test_camera_service.py).
|
||||
"""
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
MODULO = SCRIPTS / "camera_service.py"
|
||||
|
||||
|
||||
def _carica():
|
||||
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = 60) -> np.ndarray:
|
||||
"""nx x ny quadrati => (nx-1) x (ny-1) angoli interni, su fondo bianco."""
|
||||
img = np.full((ny * square_px + 2 * margin, nx * square_px + 2 * margin), 255, np.uint8)
|
||||
for j in range(ny):
|
||||
for i in range(nx):
|
||||
if (i + j) % 2 == 0:
|
||||
y, x = margin + j * square_px, margin + i * square_px
|
||||
img[y:y + square_px, x:x + square_px] = 0
|
||||
return img
|
||||
|
||||
|
||||
def una_scacchiera_densa(w: int = 640, h: int = 480, square_px: int = 40, k: float = 1e-8) -> np.ndarray:
|
||||
"""#84: scacchiera che riempie tutto il campo, con distorsione a barilotto (k sui px dal centro)."""
|
||||
m = 3 * square_px # la scacchiera continua oltre il campo: nessun bordo finto nell'immagine
|
||||
yy, xx = np.mgrid[0:h + 2 * m, 0:w + 2 * m]
|
||||
piana = (((xx // square_px) + (yy // square_px)) % 2 * 255).astype(np.uint8)
|
||||
cx, cy = w / 2.0, h / 2.0
|
||||
dx, dy = (xx[m:m + h, m:m + w] - m - cx).astype(np.float32), (yy[m:m + h, m:m + w] - m - cy).astype(np.float32)
|
||||
f = 1 + k * (dx * dx + dy * dy)
|
||||
img = cv2.remap(piana, cx + m + dx * f, cy + m + dy * f, cv2.INTER_LINEAR)
|
||||
return cv2.GaussianBlur(img, (3, 3), 0.8) # bordi non perfettamente netti, come in camera
|
||||
|
||||
|
||||
def residuo_reticolo(griglia: np.ndarray) -> float:
|
||||
"""Residuo massimo (px) della griglia (nr,nc,2) da un reticolo affine regolare."""
|
||||
nr, nc, _ = griglia.shape
|
||||
ii, jj = np.mgrid[0:nr, 0:nc]
|
||||
A = np.stack([np.ones(nr * nc), ii.ravel(), jj.ravel()], axis=1)
|
||||
P = griglia.reshape(-1, 2)
|
||||
coef, *_ = np.linalg.lstsq(A, P, rcond=None)
|
||||
return float(np.hypot(*(A @ coef - P).T).max())
|
||||
|
||||
|
||||
def _png(img: np.ndarray) -> bytes:
|
||||
ok, buf = cv2.imencode(".png", img)
|
||||
assert ok
|
||||
return buf.tobytes()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella(tmp_path):
|
||||
# la sorgente camera:folder fa il loop su una scacchiera: il grab la trova
|
||||
cartella = tmp_path / "immagini"
|
||||
cartella.mkdir()
|
||||
cv2.imwrite(str(cartella / "scacchiera.png"), una_scacchiera())
|
||||
return cartella
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(cartella, tmp_path):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella, config_dir=tmp_path / "config")) as c:
|
||||
c.config = tmp_path / "config"
|
||||
yield c
|
||||
|
||||
|
||||
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
|
||||
CHIAVI_C1 = {"px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows",
|
||||
"square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"}
|
||||
# #84: campi in piu' per metodo planare (C2 con cols/rows) e per la densa
|
||||
CHIAVI_PLANARE = CHIAVI_C1 | {"metodo", "distorsione", "origine"} # #91: + origine ("camera:X" | "file:Y")
|
||||
CHIAVI_DENSA = CHIAVI_PLANARE | {"residuo_rms_px", "residuo_max_px", "copertura_pct"}
|
||||
SORGENTE = "camera:folder"
|
||||
# #91: GET /camera/calibration = voce di libreria + "nome" + "source" (la sorgente che la usa)
|
||||
CHIAVI_ATTIVA = {"source", "nome"}
|
||||
|
||||
|
||||
def salva_con_nome(client, calcolo: dict, nome: str, **extra):
|
||||
"""#91: POST /camera/calibrazioni sul calcolo_id di una risposta di /camera/calibrate."""
|
||||
return client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": nome, **extra})
|
||||
|
||||
|
||||
def carica_su(client, sorgente: str, nome: str):
|
||||
"""#91: PUT /camera/calibrazione-attiva."""
|
||||
return client.put("/camera/calibrazione-attiva", json={"source": sorgente, "nome": nome})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella_densa(tmp_path):
|
||||
# #84: la sorgente camera:folder fa il loop su una scacchiera densa distorta
|
||||
cartella = tmp_path / "immagini_dense"
|
||||
cartella.mkdir()
|
||||
cv2.imwrite(str(cartella / "densa.png"), una_scacchiera_densa(k=1e-7))
|
||||
return cartella
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_denso(cartella_densa, tmp_path):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella_densa, config_dir=tmp_path / "config_densa")) as c:
|
||||
c.config = tmp_path / "config_densa"
|
||||
yield c
|
||||
|
||||
|
||||
NON_IMMAGINE = b"questo non e una immagine"
|
||||
|
||||
|
||||
# ---- logica pura
|
||||
|
||||
def test_scacchiera_sintetica_da_la_scala():
|
||||
_carica() # mette scripts/ in sys.path
|
||||
import calibrazione
|
||||
r = calibrazione.calibra_scacchiera(una_scacchiera(), 9, 6, 2.0)
|
||||
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||
assert r["mm_per_px"] == pytest.approx(0.05, rel=0.005)
|
||||
assert r["rms_px"] < 0.5
|
||||
assert r["n_corners"] == 54
|
||||
assert (r["image_width"], r["image_height"]) == (520, 400)
|
||||
assert r["scale_spread_pct"] < 1.0
|
||||
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def test_immagine_bianca_solleva():
|
||||
_carica()
|
||||
import calibrazione
|
||||
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
|
||||
calibrazione.calibra_scacchiera(np.full((300, 300), 255, np.uint8), 9, 6, 2.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cols,rows,quadrato", [(1, 6, 2.0), (9, 6, 0.0), (9, 6, -1.0)])
|
||||
def test_parametri_invalidi_sollevano(cols, rows, quadrato):
|
||||
_carica()
|
||||
import calibrazione
|
||||
with pytest.raises(calibrazione.CalibrazioneError):
|
||||
calibrazione.calibra_scacchiera(una_scacchiera(), cols, rows, quadrato)
|
||||
|
||||
|
||||
# ---- rotte
|
||||
|
||||
def test_c1_non_calibrata_404(client):
|
||||
r = client.get("/camera/calibration", params={"source": "camera:folder"})
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"] == "sorgente non calibrata"
|
||||
|
||||
|
||||
def test_c1_senza_source_400(client):
|
||||
assert client.get("/camera/calibration").status_code == 400
|
||||
|
||||
|
||||
def test_c2_immagine_caricata_senza_salvare(client):
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
corpo = r.json()
|
||||
assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id"} == set(corpo) # #91: niente "saved", niente "source"
|
||||
assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia
|
||||
assert corpo["origine"] == "file:s.png"
|
||||
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||
# il calcolo non salva mai
|
||||
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
||||
assert not client.config.exists() or not list(client.config.iterdir())
|
||||
|
||||
|
||||
def test_c2_save_true_viene_ignorato(client):
|
||||
# #91: il vecchio campo save non salva piu' nulla
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", "save": "true", **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
||||
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
|
||||
assert not client.config.exists() or not list(client.config.iterdir())
|
||||
|
||||
|
||||
def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client):
|
||||
# nessun file: grab da camera:folder (la scacchiera in cartella)
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["origine"] == "camera:folder"
|
||||
assert salva_con_nome(client, r.json(), "Banco").status_code == 201 # #91: si salva con nome, dopo
|
||||
|
||||
# salvata ma non ancora caricata sulla sorgente
|
||||
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
||||
assert carica_su(client, "camera:folder", "Banco").status_code == 200
|
||||
|
||||
r1 = client.get("/camera/calibration", params={"source": "camera:folder"})
|
||||
assert r1.status_code == 200
|
||||
assert set(r1.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA # nessun overlay nel file salvato
|
||||
assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||
assert r1.json()["source"] == "camera:folder" and r1.json()["nome"] == "Banco"
|
||||
file_salvati = list((client.config / "calibrazioni").glob("*.json"))
|
||||
assert len(file_salvati) == 1
|
||||
assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9
|
||||
|
||||
# DELETE /camera/calibration scollega: la libreria resta
|
||||
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
|
||||
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
||||
assert len(list((client.config / "calibrazioni").glob("*.json"))) == 1
|
||||
# scollegare di nuovo e' idempotente
|
||||
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
|
||||
|
||||
|
||||
def test_c2_scacchiera_non_trovata_422(client):
|
||||
bianca = np.full((300, 300), 255, np.uint8)
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||
files={"image": ("b.png", _png(bianca), "image/png")})
|
||||
assert r.status_code == 422
|
||||
assert "non trovata" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_c2_parametri_invalidi_422(client):
|
||||
r = client.post("/camera/calibrate",
|
||||
data={"source": "camera:folder", "cols": "1", "rows": "6", "square_mm": "2"},
|
||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_c2_immagine_illeggibile_422(client):
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||
files={"image": ("x.png", NON_IMMAGINE, "image/png")})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_c2_errori_camera_come_grab(client):
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:non-esiste", **CAMPI})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_due_nomi_simili_hanno_file_diversi(client):
|
||||
# #91: i nomi si ripuliscono come prima (hash sul nome originale): due nomi diversi non si pestano
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 200
|
||||
for nome in ("banco a b", "banco a_b"):
|
||||
assert salva_con_nome(client, r.json(), nome).status_code == 201
|
||||
assert len(list((client.config / "calibrazioni").glob("*.json"))) == 2
|
||||
|
||||
|
||||
def test_c4_pagina_html(client):
|
||||
r = client.get("/calibrazione")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/html")
|
||||
assert "Acquisisci e calibra" in r.text
|
||||
assert "Lato dello scacco" in r.text and 'id="cols"' not in r.text # #84: un solo campo
|
||||
assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN
|
||||
|
||||
|
||||
# ---- #84: calibrazione densa (logica pura)
|
||||
|
||||
def test_densa_trova_la_griglia_da_sola_e_la_scala():
|
||||
_carica()
|
||||
import calibrazione
|
||||
r, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(k=1e-8), 2.0)
|
||||
assert (r["cols"], r["rows"]) == (15, 11) # 640x480 con quadrati da 40 px
|
||||
assert r["n_corners"] == 15 * 11
|
||||
assert r["mm_per_px"] == pytest.approx(2.0 / 40, rel=0.005)
|
||||
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||
assert r["metodo"] == "densa" and r["distorsione"] is True
|
||||
assert r["residuo_max_px"] < 0.3 and r["residuo_rms_px"] <= r["residuo_max_px"]
|
||||
assert 60 < r["copertura_pct"] <= 100
|
||||
assert (r["image_width"], r["image_height"]) == (640, 480)
|
||||
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
assert mappa.shape == (480, 640) and mappa.pixel_size == pytest.approx(0.05, rel=0.005)
|
||||
|
||||
|
||||
def test_densa_raddrizza_su_griglia_regolare():
|
||||
_carica()
|
||||
import calibrazione
|
||||
img = una_scacchiera_densa(k=1e-7) # distorsione marcata: ai bordi ~5 %
|
||||
r, mappa = calibrazione.calibra_scacchiera_densa(img, 2.0)
|
||||
assert (r["cols"], r["rows"]) == (15, 11)
|
||||
# prima la griglia e' curva, dopo e' un reticolo regolare
|
||||
assert residuo_reticolo(calibrazione._trova_griglia(img)) > 1.0
|
||||
pronta = calibrazione._prepara_mappa(mappa)
|
||||
dritta = calibrazione.raddrizza(img, pronta)
|
||||
assert dritta.shape == img.shape
|
||||
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cosa", ["bianca", "scacchiera_piccola"])
|
||||
def test_densa_senza_griglia_solleva(cosa):
|
||||
_carica()
|
||||
import calibrazione
|
||||
img = (np.full((300, 300), 255, np.uint8) if cosa == "bianca"
|
||||
else una_scacchiera_densa(80, 60, 20)) # 3x2 angoli: meno di 3x3
|
||||
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
|
||||
calibrazione.calibra_scacchiera_densa(img, 2.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("quadrato", [0.0, -1.0, float("nan")])
|
||||
def test_densa_quadrato_invalido_solleva(quadrato):
|
||||
_carica()
|
||||
import calibrazione
|
||||
with pytest.raises(calibrazione.CalibrazioneError):
|
||||
calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), quadrato)
|
||||
|
||||
|
||||
def test_archivio_mappa_cache_e_invalidazione(tmp_path):
|
||||
_carica()
|
||||
import calibrazione
|
||||
_, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)
|
||||
arch = calibrazione.Archivio(tmp_path)
|
||||
assert arch.leggi_mappa("s") is None
|
||||
arch.salva_nome("cal", {"a": 1}, mappa) # #91: salvataggio con nome + attivazione sulla sorgente
|
||||
arch.attiva("s", "cal")
|
||||
prima = arch.leggi_mappa("s")
|
||||
assert prima is not None and arch.leggi_mappa("s") is prima # cache in memoria
|
||||
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
|
||||
arch.salva_nome("cal", {"a": 2}, mappa, sovrascrivi=True) # file riscritto -> cache invalidata
|
||||
assert arch.leggi_mappa("s") is not prima
|
||||
arch.salva_nome("cal", {"a": 3}, sovrascrivi=True) # salvataggio senza mappa (planare): la vecchia sparisce
|
||||
assert arch.leggi_mappa("s") is None and not list(tmp_path.rglob("*.npz"))
|
||||
|
||||
|
||||
# ---- #84: rotte con la calibrazione densa
|
||||
|
||||
def _grigio(r) -> np.ndarray:
|
||||
return cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
|
||||
|
||||
|
||||
def test_c2_senza_cols_rows_e_densa(client_denso):
|
||||
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||
assert r.status_code == 200, r.text
|
||||
corpo = r.json()
|
||||
assert CHIAVI_DENSA | {"overlay_png", "calcolo_id"} == set(corpo)
|
||||
assert corpo["metodo"] == "densa" and corpo["distorsione"] is True
|
||||
assert (corpo["cols"], corpo["rows"]) == (15, 11)
|
||||
assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"])
|
||||
assert not client_denso.config.exists() or not list(client_denso.config.iterdir())
|
||||
|
||||
|
||||
def test_c2_solo_cols_o_solo_rows_422(client_denso):
|
||||
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "cols": "9"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_grab_raddrizzato_se_calibrata_e_grezzo_a_richiesta(client_denso, cartella_densa):
|
||||
originale = cv2.imread(str(cartella_densa / "densa.png"), cv2.IMREAD_GRAYSCALE)
|
||||
prima = client_denso.post("/camera/grab", json={"source": SORGENTE})
|
||||
assert prima.headers["x-raddrizzato"] == "no" # non calibrata: frame com'e'
|
||||
assert np.array_equal(_grigio(prima), originale)
|
||||
|
||||
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert salva_con_nome(client_denso, r.json(), "Densa").status_code == 201 # #91: salvata con nome
|
||||
libreria = client_denso.config / "calibrazioni"
|
||||
json_salvati, mappe = list(libreria.glob("*.json")), list(libreria.glob("*.npz"))
|
||||
assert len(json_salvati) == 1 and len(mappe) == 1
|
||||
assert mappe[0].stem == json_salvati[0].stem # stesso nome del JSON
|
||||
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" # salvata != attiva
|
||||
assert carica_su(client_denso, SORGENTE, "Densa").status_code == 200
|
||||
saved = client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()
|
||||
assert set(saved) == CHIAVI_DENSA | CHIAVI_ATTIVA and saved["metodo"] == "densa"
|
||||
|
||||
dopo = client_denso.post("/camera/grab", json={"source": SORGENTE})
|
||||
assert dopo.status_code == 200 and dopo.headers["x-raddrizzato"] == "si"
|
||||
import calibrazione
|
||||
dritta = _grigio(dopo)
|
||||
assert dritta.shape == originale.shape
|
||||
assert not np.array_equal(dritta, originale)
|
||||
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3 # griglia regolare
|
||||
|
||||
grezzo = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": True})
|
||||
assert grezzo.headers["x-raddrizzato"] == "no"
|
||||
assert np.array_equal(_grigio(grezzo), originale)
|
||||
|
||||
# la calibrazione parte sempre dal frame grezzo: ricalibrare dalla sorgente da' lo stesso mm/px
|
||||
di_nuovo = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||
assert di_nuovo.json()["mm_per_px"] == pytest.approx(r.json()["mm_per_px"])
|
||||
assert di_nuovo.json()["residuo_max_px"] < 0.3
|
||||
|
||||
|
||||
def test_grab_grezzo_non_booleano_400(client_denso):
|
||||
r = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": "si"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_scollega_lascia_la_libreria_ed_elimina_toglie_la_mappa(client_denso):
|
||||
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||
salva_con_nome(client_denso, r.json(), "Densa")
|
||||
carica_su(client_denso, SORGENTE, "Densa")
|
||||
libreria = client_denso.config / "calibrazioni"
|
||||
assert list(libreria.glob("*.npz"))
|
||||
# scollegare: il frame torna com'e', ma la voce (e la mappa) restano in libreria
|
||||
assert client_denso.delete("/camera/calibration", params={"source": SORGENTE}).status_code == 204
|
||||
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
|
||||
assert list(libreria.glob("*.npz")) and list(libreria.glob("*.json"))
|
||||
# eliminare la voce toglie JSON e mappa
|
||||
carica_su(client_denso, SORGENTE, "Densa")
|
||||
assert client_denso.delete("/camera/calibrazioni", params={"nome": "Densa"}).status_code == 204
|
||||
assert not list(libreria.glob("*.npz")) and not list(libreria.glob("*.json"))
|
||||
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
|
||||
|
||||
|
||||
def test_ricalibrare_planare_toglie_la_mappa_densa(client_denso):
|
||||
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||
salva_con_nome(client_denso, densa.json(), "Cal")
|
||||
carica_su(client_denso, SORGENTE, "Cal")
|
||||
libreria = client_denso.config / "calibrazioni"
|
||||
assert list(libreria.glob("*.npz"))
|
||||
planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI},
|
||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||
assert planare.status_code == 200 and planare.json()["metodo"] == "planare"
|
||||
assert salva_con_nome(client_denso, planare.json(), "Cal").status_code == 409 # nome gia' preso
|
||||
assert salva_con_nome(client_denso, planare.json(), "Cal", sovrascrivi=True).status_code == 201
|
||||
assert not list(libreria.glob("*.npz")) # niente mappa vecchia che raddrizza a vuoto
|
||||
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Test dell'archivio unico di calibrazioni con nome (#91): scripts/calibrazione.py, classe Archivio.
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Solo tmp_path: niente ProgramData, niente camera.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from test_calibrazione import _carica, una_scacchiera_densa
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mappa_densa():
|
||||
# #91: una mappa vera, calcolata una volta sola per tutti i test del modulo
|
||||
_carica()
|
||||
import calibrazione
|
||||
return calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)[1]
|
||||
|
||||
|
||||
def _dati(**extra):
|
||||
base = {"px_per_mm": 20.0, "mm_per_px": 0.05, "image_width": 640, "image_height": 480,
|
||||
"metodo": "planare", "square_mm": 2.0, "calibrated_at": "2026-10-24T10:00:00+00:00",
|
||||
"origine": "file:s.png"}
|
||||
return {**base, **extra}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cal():
|
||||
_carica()
|
||||
import calibrazione
|
||||
return calibrazione
|
||||
|
||||
|
||||
# ---- nomi
|
||||
|
||||
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x00b", "a\nb", "a\tb", None, 5])
|
||||
def test_nome_non_valido(cal, tmp_path, nome):
|
||||
with pytest.raises(cal.NomeNonValido):
|
||||
cal.Archivio(tmp_path).salva_nome(nome, _dati())
|
||||
|
||||
|
||||
def test_nome_valido_limiti_e_strip(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("x" * 60, _dati())
|
||||
meta = arch.salva_nome(" Banco 1 ", _dati())
|
||||
assert meta["nome"] == "Banco 1" # strip
|
||||
assert arch.leggi_nome("Banco 1")["nome"] == "Banco 1"
|
||||
|
||||
|
||||
def test_nome_unico_senza_maiuscole(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
|
||||
with pytest.raises(cal.NomeGiaUsato, match="Banco"):
|
||||
arch.salva_nome("BANCO", _dati(px_per_mm=2.0))
|
||||
assert arch.leggi_nome("banco")["px_per_mm"] == 1.0 # non toccata
|
||||
assert len(arch.elenco()) == 1
|
||||
|
||||
|
||||
def test_sovrascrivi_sostituisce_e_prende_il_nuovo_nome(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
|
||||
arch.salva_nome("BANCO", _dati(px_per_mm=2.0), sovrascrivi=True)
|
||||
voci = arch.elenco()
|
||||
assert len(voci) == 1 and voci[0]["nome"] == "BANCO" and voci[0]["px_per_mm"] == 2.0
|
||||
assert len(list((tmp_path / "calibrazioni").glob("*.json"))) == 1
|
||||
|
||||
|
||||
def test_salva_non_scrive_source_ne_overlay(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("A", _dati(source="camera:x", overlay_png="abc", saved=True))
|
||||
scritto = json.loads(next((tmp_path / "calibrazioni").glob("*.json")).read_text(encoding="utf-8"))
|
||||
assert "source" not in scritto and "overlay_png" not in scritto and "saved" not in scritto
|
||||
assert scritto["nome"] == "A" and scritto["origine"] == "file:s.png"
|
||||
|
||||
|
||||
def test_leggi_nome_inesistente_o_non_valido(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
assert arch.leggi_nome("nessuna") is None and arch.leggi_nome("") is None
|
||||
|
||||
|
||||
# ---- elenco e attive
|
||||
|
||||
def test_elenco_dalla_piu_recente_e_attiva_su(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("vecchia", _dati(calibrated_at="2026-01-01T00:00:00+00:00"))
|
||||
arch.salva_nome("nuova", _dati(calibrated_at="2026-10-01T00:00:00+00:00"))
|
||||
arch.attiva("camera:b", "nuova")
|
||||
arch.attiva("camera:a", "nuova")
|
||||
voci = arch.elenco()
|
||||
assert [v["nome"] for v in voci] == ["nuova", "vecchia"]
|
||||
assert voci[0]["attiva_su"] == ["camera:a", "camera:b"] and voci[1]["attiva_su"] == []
|
||||
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm",
|
||||
"mm_per_px", "metodo", "square_mm", "calibrated_at", "attiva_su"}
|
||||
|
||||
|
||||
def test_attiva_leggi_scollega(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("Banco", _dati())
|
||||
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
|
||||
assert arch.attiva("camera:a", "banco") == "Banco" # nome come in libreria
|
||||
assert arch.attiva_di("camera:a") == "Banco"
|
||||
letta = arch.leggi("camera:a")
|
||||
assert letta["nome"] == "Banco" and letta["source"] == "camera:a" and letta["px_per_mm"] == 20.0
|
||||
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:a": "Banco"}
|
||||
arch.scollega("camera:a")
|
||||
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
|
||||
assert arch.leggi_nome("Banco") is not None # la libreria resta
|
||||
arch.scollega("camera:a") # idempotente
|
||||
|
||||
|
||||
def test_attiva_nome_sconosciuto(cal, tmp_path):
|
||||
with pytest.raises(cal.NomeSconosciuto):
|
||||
cal.Archivio(tmp_path).attiva("camera:a", "nessuna")
|
||||
|
||||
|
||||
def test_due_sorgenti_stessa_calibrazione(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("Comune", _dati())
|
||||
arch.attiva("camera:a", "Comune")
|
||||
arch.attiva("camera:b", "Comune")
|
||||
assert arch.leggi("camera:a")["source"] == "camera:a" and arch.leggi("camera:b")["source"] == "camera:b"
|
||||
|
||||
|
||||
def test_elimina_nome_toglie_da_attive_e_mappa(cal, tmp_path, mappa_densa):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
|
||||
arch.salva_nome("Altra", _dati())
|
||||
arch.attiva("camera:a", "Densa")
|
||||
arch.attiva("camera:b", "Densa")
|
||||
arch.attiva("camera:c", "Altra")
|
||||
assert arch.leggi_mappa("camera:a") is not None
|
||||
assert arch.elimina_nome("DENSA") is True
|
||||
assert arch.leggi_nome("Densa") is None and not list((tmp_path / "calibrazioni").glob("*.npz"))
|
||||
assert arch.attiva_di("camera:a") is None and arch.attiva_di("camera:b") is None
|
||||
assert arch.leggi_mappa("camera:a") is None
|
||||
assert arch.attiva_di("camera:c") == "Altra"
|
||||
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:c": "Altra"}
|
||||
assert arch.elimina_nome("Densa") is False # gia' tolta
|
||||
|
||||
|
||||
# ---- mappa
|
||||
|
||||
def test_leggi_mappa_risolve_l_attiva_con_cache(cal, tmp_path, mappa_densa):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
assert arch.leggi_mappa("camera:a") is None
|
||||
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
|
||||
assert arch.leggi_mappa("camera:a") is None # non attiva su camera:a
|
||||
arch.attiva("camera:a", "Densa")
|
||||
prima = arch.leggi_mappa("camera:a")
|
||||
assert prima is not None and arch.leggi_mappa("camera:a") is prima # cache in memoria
|
||||
assert arch.leggi_mappa("camera:b") is None
|
||||
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
|
||||
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa, sovrascrivi=True) # file riscritto
|
||||
assert arch.leggi_mappa("camera:a") is not prima # cache invalidata
|
||||
arch.salva_nome("Densa", _dati(), sovrascrivi=True) # sovrascritta con una planare: la mappa sparisce
|
||||
assert arch.leggi_mappa("camera:a") is None and not list(tmp_path.rglob("*.npz"))
|
||||
|
||||
|
||||
# ---- migrazione dal formato vecchio
|
||||
|
||||
def _vecchia(cartella, cal, source, mappa=None, **extra):
|
||||
"""Scrive una calibrazione nel formato vecchio (un JSON per sorgente, + .npz) come faceva Archivio.salva."""
|
||||
dati = {"source": source, **_dati(**extra)}
|
||||
dati.pop("origine")
|
||||
nudo = cal.Archivio.__new__(cal.Archivio) # senza costruttore: niente migrazione mentre si prepara
|
||||
nudo.cartella = cartella
|
||||
percorso = nudo._file(source)
|
||||
cartella.mkdir(parents=True, exist_ok=True)
|
||||
percorso.write_text(json.dumps(dati), encoding="utf-8")
|
||||
if mappa is not None:
|
||||
mappa.save(str(percorso.with_suffix(".npz")))
|
||||
return percorso
|
||||
|
||||
|
||||
def test_migrazione_formato_vecchio(cal, tmp_path, mappa_densa):
|
||||
v1 = _vecchia(tmp_path, cal, "camera:601020", mappa_densa, metodo="densa", px_per_mm=7.0)
|
||||
v2 = _vecchia(tmp_path, cal, "camera:folder", px_per_mm=9.0)
|
||||
(tmp_path / "settaggi").mkdir()
|
||||
(tmp_path / "settaggi" / v1.name).write_text("{}", encoding="utf-8") # non si tocca
|
||||
arch = cal.Archivio(tmp_path)
|
||||
assert not v1.exists() and not v1.with_suffix(".npz").exists() and not v2.exists()
|
||||
assert {v["nome"] for v in arch.elenco()} == {"camera:601020", "camera:folder"}
|
||||
assert arch.attiva_di("camera:601020") == "camera:601020" and arch.attiva_di("camera:folder") == "camera:folder"
|
||||
letta = arch.leggi("camera:601020")
|
||||
assert letta["px_per_mm"] == 7.0 and letta["source"] == "camera:601020"
|
||||
assert arch.leggi_mappa("camera:601020") is not None # il .npz ha seguito il JSON
|
||||
assert len(list((tmp_path / "calibrazioni").glob("*.npz"))) == 1
|
||||
assert (tmp_path / "settaggi" / v1.name).read_text(encoding="utf-8") == "{}"
|
||||
|
||||
|
||||
def test_migrazione_idempotente(cal, tmp_path):
|
||||
_vecchia(tmp_path, cal, "camera:a")
|
||||
cal.Archivio(tmp_path)
|
||||
prima = sorted(p.name for p in tmp_path.rglob("*") if p.is_file())
|
||||
arch = cal.Archivio(tmp_path) # secondo avvio
|
||||
arch.migra()
|
||||
assert sorted(p.name for p in tmp_path.rglob("*") if p.is_file()) == prima
|
||||
assert [v["nome"] for v in arch.elenco()] == ["camera:a"]
|
||||
|
||||
|
||||
def test_migrazione_nome_gia_preso(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("camera:a", _dati(px_per_mm=1.0, calibrated_at="2020-01-01T00:00:00+00:00"))
|
||||
_vecchia(tmp_path, cal, "camera:a", px_per_mm=5.0)
|
||||
arch.migra()
|
||||
nomi = {v["nome"]: v["px_per_mm"] for v in arch.elenco()}
|
||||
assert nomi == {"camera:a": 1.0, "camera:a (2)": 5.0}
|
||||
assert arch.attiva_di("camera:a") == "camera:a (2)" # la sorgente usa la sua, non quella gia' presente
|
||||
# un altro file con lo stesso nome -> (3)
|
||||
_vecchia(tmp_path, cal, "camera:A", px_per_mm=6.0) # nome uguale senza maiuscole
|
||||
arch.migra()
|
||||
assert "camera:A (3)" in {v["nome"] for v in arch.elenco()}
|
||||
|
||||
|
||||
def test_migrazione_ignora_json_senza_source_e_attive(cal, tmp_path):
|
||||
tmp_path.mkdir(exist_ok=True)
|
||||
(tmp_path / "altro.json").write_text(json.dumps({"x": 1}), encoding="utf-8")
|
||||
(tmp_path / "rotto.json").write_text("{non json", encoding="utf-8")
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("A", _dati())
|
||||
arch.attiva("source", "A") # una sorgente chiamata «source» non deve far scambiare attive.json per un vecchio file
|
||||
arch.migra()
|
||||
assert (tmp_path / "altro.json").exists() and (tmp_path / "rotto.json").exists()
|
||||
assert arch.attiva_di("source") == "A" and [v["nome"] for v in arch.elenco()] == ["A"]
|
||||
|
||||
|
||||
def test_cartella_assente_non_da_errore(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path / "non" / "esiste")
|
||||
assert arch.elenco() == [] and arch.leggi("s") is None and arch.leggi_mappa("s") is None
|
||||
arch.scollega("s")
|
||||
assert arch.elimina_nome("x") is False
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Test delle rotte dell'archivio unico di calibrazioni con nome (#91): scripts/camera_service.py.
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Sorgenti: camera:folder su cartelle temporanee (520x400 scacchiera piana, 640x480 densa).
|
||||
Solo tmp_path: niente ProgramData, niente camera vera.
|
||||
"""
|
||||
import json
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
|
||||
CAMPI, CHIAVI_ATTIVA, CHIAVI_DENSA, CHIAVI_PLANARE, SORGENTE, _carica, _png,
|
||||
carica_su, cartella, cartella_densa, client, client_denso, salva_con_nome,
|
||||
una_scacchiera, una_scacchiera_densa,
|
||||
)
|
||||
|
||||
__all__ = ["cartella", "cartella_densa", "client", "client_denso"] # le fixture importate restano usate
|
||||
|
||||
|
||||
def _calcola_file(c, **extra):
|
||||
"""Calcolo planare da un'immagine (520x400): ritorna la risposta JSON."""
|
||||
r = c.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI, **extra},
|
||||
files={"image": ("scacco.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---- POST /camera/calibrate
|
||||
|
||||
def test_calibrate_senza_source_con_immagine_ok(client):
|
||||
r = client.post("/camera/calibrate", data=CAMPI,
|
||||
files={"image": ("C:\\foto\\scacco.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["origine"] == "file:scacco.png" # solo il nome del file, non il percorso
|
||||
|
||||
|
||||
def test_calibrate_senza_source_e_senza_immagine_400(client):
|
||||
r = client.post("/camera/calibrate", data=CAMPI)
|
||||
assert r.status_code == 400 and "source" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_calibrate_da_sorgente_ha_origine_camera_e_id(client):
|
||||
r = client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
corpo = r.json()
|
||||
assert corpo["origine"] == SORGENTE and corpo["calibrated_at"]
|
||||
assert len(corpo["calcolo_id"]) == 32 and int(corpo["calcolo_id"], 16) >= 0 # esadecimale
|
||||
assert corpo["calcolo_id"] != client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI}).json()["calcolo_id"]
|
||||
|
||||
|
||||
# ---- POST /camera/calibrazioni
|
||||
|
||||
def test_salva_201_con_metadati(client):
|
||||
calcolo = _calcola_file(client)
|
||||
r = salva_con_nome(client, calcolo, " Banco 1 ")
|
||||
assert r.status_code == 201, r.text
|
||||
meta = r.json()
|
||||
assert meta["nome"] == "Banco 1" and meta["origine"] == "file:scacco.png"
|
||||
assert (meta["image_width"], meta["image_height"]) == (520, 400)
|
||||
assert meta["metodo"] == "planare" and meta["attiva_su"] == []
|
||||
assert meta["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||
# lo stesso calcolo si puo' salvare anche con un altro nome
|
||||
assert salva_con_nome(client, calcolo, "Banco 2").status_code == 201
|
||||
|
||||
|
||||
def test_salva_calcolo_sconosciuto_404(client):
|
||||
r = client.post("/camera/calibrazioni", json={"calcolo_id": "0" * 32, "nome": "X"})
|
||||
assert r.status_code == 404 and r.json()["detail"] == "calcolo non più disponibile: ricalcolare"
|
||||
|
||||
|
||||
def test_salva_calcolo_scaduto_dopo_otto_calcoli_404(client):
|
||||
primo = _calcola_file(client)
|
||||
ultimo = primo
|
||||
for _ in range(8): # il nono calcolo spinge fuori il primo
|
||||
ultimo = _calcola_file(client)
|
||||
assert salva_con_nome(client, primo, "Vecchio").status_code == 404
|
||||
assert salva_con_nome(client, ultimo, "Nuovo").status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x01b", None, 7])
|
||||
def test_salva_nome_non_valido_400(client, nome):
|
||||
r = client.post("/camera/calibrazioni", json={"calcolo_id": _calcola_file(client)["calcolo_id"], "nome": nome})
|
||||
assert r.status_code == 400, r.text
|
||||
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
|
||||
|
||||
|
||||
def test_salva_corpo_incompleto_400(client):
|
||||
assert client.post("/camera/calibrazioni", json={"nome": "X"}).status_code == 400
|
||||
calcolo = _calcola_file(client)
|
||||
r = client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": "X", "sovrascrivi": "si"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_salva_nome_gia_usato_409_e_sovrascrivi(client):
|
||||
a = _calcola_file(client)
|
||||
assert salva_con_nome(client, a, "Banco").status_code == 201
|
||||
r = salva_con_nome(client, a, "BANCO") # senza distinguere maiuscole
|
||||
assert r.status_code == 409 and r.json()["detail"] == "esiste già una calibrazione «Banco»"
|
||||
assert len(client.get("/camera/calibrazioni").json()["calibrazioni"]) == 1
|
||||
r = salva_con_nome(client, a, "BANCO", sovrascrivi=True)
|
||||
assert r.status_code == 201 and r.json()["nome"] == "BANCO"
|
||||
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
|
||||
assert [v["nome"] for v in voci] == ["BANCO"]
|
||||
|
||||
|
||||
# ---- GET /camera/calibrazioni
|
||||
|
||||
def test_elenco_vuoto_e_ordine(client):
|
||||
assert client.get("/camera/calibrazioni").json() == {"calibrazioni": [], "attiva": None}
|
||||
prima = _calcola_file(client)
|
||||
salva_con_nome(client, prima, "prima")
|
||||
seconda = _calcola_file(client)
|
||||
seconda_ts = seconda["calibrated_at"]
|
||||
salva_con_nome(client, seconda, "seconda")
|
||||
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
|
||||
assert {v["nome"] for v in voci} == {"prima", "seconda"}
|
||||
assert [v["calibrated_at"] for v in voci] == sorted((v["calibrated_at"] for v in voci), reverse=True)
|
||||
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm", "mm_per_px",
|
||||
"metodo", "square_mm", "calibrated_at", "attiva_su"}
|
||||
assert seconda_ts >= prima["calibrated_at"]
|
||||
|
||||
|
||||
def test_elenco_con_source_dice_l_attiva(client):
|
||||
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||
salva_con_nome(client, _calcola_file(client), "Altra")
|
||||
assert carica_su(client, SORGENTE, "banco").status_code == 200
|
||||
r = client.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
|
||||
assert r["attiva"] == "Banco"
|
||||
attiva_su = {v["nome"]: v["attiva_su"] for v in r["calibrazioni"]}
|
||||
assert attiva_su == {"Banco": [SORGENTE], "Altra": []}
|
||||
assert client.get("/camera/calibrazioni", params={"source": "camera:altra"}).json()["attiva"] is None
|
||||
assert client.get("/camera/calibrazioni", params={"source": " "}).status_code == 400
|
||||
|
||||
|
||||
# ---- DELETE /camera/calibrazioni
|
||||
|
||||
def test_elimina_204_404_e_toglie_dalle_sorgenti(client):
|
||||
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||
carica_su(client, SORGENTE, "Banco")
|
||||
assert client.delete("/camera/calibrazioni", params={"nome": "BANCO"}).status_code == 204
|
||||
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
|
||||
assert client.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # non piu' attiva
|
||||
assert json.loads((client.config / "attive.json").read_text(encoding="utf-8")) == {}
|
||||
assert client.delete("/camera/calibrazioni", params={"nome": "Banco"}).status_code == 404
|
||||
assert client.delete("/camera/calibrazioni").status_code == 404 # senza nome
|
||||
|
||||
|
||||
# ---- PUT /camera/calibrazione-attiva
|
||||
|
||||
def test_carica_risoluzione_uguale_200_come_get(client):
|
||||
salva_con_nome(client, _calcola_file(client), "Banco") # 520x400 = la scacchiera in cartella
|
||||
r = carica_su(client, SORGENTE, "banco")
|
||||
assert r.status_code == 200, r.text
|
||||
assert set(r.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA
|
||||
assert r.json()["nome"] == "Banco" and r.json()["source"] == SORGENTE
|
||||
assert client.get("/camera/calibration", params={"source": SORGENTE}).json() == r.json()
|
||||
|
||||
|
||||
def test_carica_risoluzione_diversa_409_e_anteprima_continua(client_denso):
|
||||
# calibrazione da file 520x400, sorgente 640x480
|
||||
salva_con_nome(client_denso, _calcola_file(client_denso), "Piccola")
|
||||
r = carica_su(client_denso, SORGENTE, "Piccola")
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == ("la calibrazione «Piccola» è per immagini 520×400, "
|
||||
"la sorgente dà 640×480: non si può caricare")
|
||||
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # nulla attivato
|
||||
# l'acquisizione continua: grab e anteprima rispondono, frame non raddrizzato
|
||||
g = client_denso.post("/camera/grab", json={"source": SORGENTE})
|
||||
assert g.status_code == 200 and g.headers["x-raddrizzato"] == "no"
|
||||
a = client_denso.post("/camera/anteprima", json={"source": SORGENTE})
|
||||
assert a.status_code == 200 and a.headers["x-raddrizzato"] == "no"
|
||||
assert (a.headers["x-larghezza"], a.headers["x-altezza"]) == ("640", "480")
|
||||
|
||||
|
||||
def test_carica_risoluzione_diversa_non_scalza_l_attiva_precedente(client_denso):
|
||||
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
|
||||
salva_con_nome(client_denso, densa, "Giusta")
|
||||
salva_con_nome(client_denso, _calcola_file(client_denso), "Sbagliata")
|
||||
assert carica_su(client_denso, SORGENTE, "Giusta").status_code == 200
|
||||
assert carica_su(client_denso, SORGENTE, "Sbagliata").status_code == 409
|
||||
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()["nome"] == "Giusta"
|
||||
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
|
||||
|
||||
|
||||
def test_carica_densa_su_sorgente_stessa_risoluzione_raddrizza(client_denso):
|
||||
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
|
||||
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
|
||||
r = carica_su(client_denso, SORGENTE, "Densa")
|
||||
assert r.status_code == 200 and set(r.json()) == CHIAVI_DENSA | CHIAVI_ATTIVA
|
||||
assert client_denso.post("/camera/anteprima", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
|
||||
|
||||
|
||||
def test_carica_nome_sconosciuto_404(client):
|
||||
r = carica_su(client, SORGENTE, "nessuna")
|
||||
assert r.status_code == 404
|
||||
assert client.put("/camera/calibrazione-attiva", json={"source": SORGENTE}).status_code == 404 # senza nome
|
||||
assert client.put("/camera/calibrazione-attiva", json={"nome": "x"}).status_code == 400 # senza source
|
||||
|
||||
|
||||
def test_carica_su_sorgente_inesistente_404(client):
|
||||
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||
assert carica_su(client, "camera:non-esiste", "Banco").status_code == 404
|
||||
|
||||
|
||||
def test_carica_senza_risoluzione_registrata_409(cartella, tmp_path):
|
||||
_carica()
|
||||
import calibrazione
|
||||
config = tmp_path / "config_senza"
|
||||
calibrazione.Archivio(config).salva_nome("Senza", {"px_per_mm": 10.0, "calibrated_at": "2026-01-01T00:00:00+00:00"})
|
||||
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
|
||||
r = carica_su(c, SORGENTE, "Senza")
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "la calibrazione «Senza» non ha la risoluzione registrata: non si può caricare"
|
||||
|
||||
|
||||
# ---- contratto verso il backend e migrazione all'avvio
|
||||
|
||||
def test_get_calibration_contratto_per_il_backend(client):
|
||||
# src/backend/services/vision_service.py legge questi campi
|
||||
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||
carica_su(client, SORGENTE, "Banco")
|
||||
dati = client.get("/camera/calibration", params={"source": SORGENTE}).json()
|
||||
for campo in ("px_per_mm", "mm_per_px", "image_width", "image_height", "calibrated_at", "metodo"):
|
||||
assert campo in dati
|
||||
assert client.get("/camera/calibration", params={"source": "camera:altra"}).json() == {
|
||||
"detail": "sorgente non calibrata"}
|
||||
|
||||
|
||||
def test_avvio_migra_il_formato_vecchio(cartella, tmp_path):
|
||||
_carica()
|
||||
import calibrazione
|
||||
config = tmp_path / "config_vecchia"
|
||||
config.mkdir()
|
||||
nudo = calibrazione.Archivio.__new__(calibrazione.Archivio) # per avere il nome file del formato vecchio
|
||||
nudo.cartella = config
|
||||
vecchio = nudo._file(SORGENTE)
|
||||
vecchio.write_text(json.dumps({"source": SORGENTE, "px_per_mm": 20.0, "mm_per_px": 0.05,
|
||||
"image_width": 520, "image_height": 400, "metodo": "planare",
|
||||
"calibrated_at": "2026-10-01T10:00:00+00:00"}), encoding="utf-8")
|
||||
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
|
||||
dati = c.get("/camera/calibration", params={"source": SORGENTE}).json()
|
||||
assert dati["nome"] == SORGENTE and dati["source"] == SORGENTE and dati["px_per_mm"] == 20.0
|
||||
voci = c.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
|
||||
assert [v["nome"] for v in voci["calibrazioni"]] == [SORGENTE] and voci["attiva"] == SORGENTE
|
||||
assert voci["calibrazioni"][0]["attiva_su"] == [SORGENTE]
|
||||
assert not vecchio.exists()
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Test delle rotte di acquisizione di scripts/camera_service.py (#87).
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Copre: POST /camera/anteprima, GET/PUT /camera/parametri, POST /camera/parametri/salva,
|
||||
caricamento dei settaggi alla riapertura, alias GET /acquisizione.
|
||||
Sorgente regolabile finta (iniettata su open_source) e FolderCamera su PNG temporanei.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from visionsuite.camera.camera_interface import CameraStateError
|
||||
|
||||
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||
SORGENTE = "camera:finta"
|
||||
|
||||
|
||||
def _carica():
|
||||
spec = importlib.util.spec_from_file_location("camera_service_acq", MODULO)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class Finta:
|
||||
"""Camera regolabile finta: stesso vocabolario di GenicamSource."""
|
||||
|
||||
DOMINI = {
|
||||
"esposizione": {"tipo": "numero", "min": 10.0, "max": 100000.0, "passo": 1.0},
|
||||
"esposizione_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
|
||||
"guadagno": {"tipo": "numero", "min": 0.0, "max": 24.0},
|
||||
"guadagno_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
|
||||
}
|
||||
|
||||
def __init__(self, supportati=None, rifiuta=()):
|
||||
self.valori = {"esposizione": 5000.0, "esposizione_auto": "Continuous",
|
||||
"guadagno": 1.0, "guadagno_auto": "Off"}
|
||||
self.supportati = set(supportati if supportati is not None else self.DOMINI)
|
||||
self.rifiuta = set(rifiuta)
|
||||
self.scritture = []
|
||||
self.caricato = None
|
||||
self.chiusa = False
|
||||
# metà bianca (255) e metà a 100: saturati 50%, media 177,5
|
||||
frame = np.full((1000, 2000, 3), 100, np.uint8)
|
||||
frame[:, :1000] = 255
|
||||
self.frame = frame
|
||||
|
||||
def supporta(self, nome):
|
||||
return nome in self.supportati
|
||||
|
||||
def dominio(self, nome):
|
||||
return {"capacita": nome, "valore": self.valori[nome], **self.DOMINI[nome]}
|
||||
|
||||
def leggi(self, nome):
|
||||
return self.valori[nome]
|
||||
|
||||
def scrivi(self, nome, valore, assesta=0):
|
||||
if nome in self.rifiuta:
|
||||
raise CameraStateError(f"non posso impostare {nome}: nodo in sola lettura")
|
||||
self.scritture.append((nome, valore, assesta))
|
||||
self.valori[nome] = valore
|
||||
|
||||
def salva_settaggi(self, percorso):
|
||||
Path(percorso).write_text(json.dumps(self.valori), encoding="utf-8")
|
||||
return dict(self.valori)
|
||||
|
||||
def carica_settaggi(self, percorso, forza=False):
|
||||
self.caricato = Path(percorso)
|
||||
self.valori.update(json.loads(self.caricato.read_text(encoding="utf-8")))
|
||||
return []
|
||||
|
||||
def grab(self, timeout=0):
|
||||
return self.frame.copy()
|
||||
|
||||
def close(self):
|
||||
self.chiusa = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella(tmp_path):
|
||||
img = tmp_path / "foto"
|
||||
img.mkdir()
|
||||
for nome in ("a.png", "b.png"):
|
||||
cv2.imwrite(str(img / nome), np.full((48, 64, 3), 128, np.uint8))
|
||||
return img
|
||||
|
||||
|
||||
def _client(cartella, config, monkeypatch, finta):
|
||||
mod = _carica()
|
||||
monkeypatch.setattr(mod, "open_source", lambda *a, **k: finta)
|
||||
c = TestClient(mod.create_app(cartella, config))
|
||||
c.mod = mod
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finta():
|
||||
return Finta()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(cartella, tmp_path, monkeypatch, finta):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
|
||||
c.finta = finta
|
||||
c.config = tmp_path / "cfg"
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_cartella(cartella, tmp_path):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella, tmp_path / "cfg")) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---- anteprima
|
||||
|
||||
def test_anteprima_jpeg_ridotta_e_header(client):
|
||||
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 500})
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert img.shape[:2] == (250, 500) # lato lungo 2000 -> 500
|
||||
assert r.headers["x-raddrizzato"] == "no"
|
||||
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("2000", "1000") # frame pieno
|
||||
assert r.headers["x-saturati-pct"] == "50.00"
|
||||
assert r.headers["x-media"] == "177.5"
|
||||
assert float(r.headers["x-nitidezza"]) > 0 # bordo netto fra le due metà
|
||||
|
||||
|
||||
def test_anteprima_mai_ingrandita(client_cartella):
|
||||
r = client_cartella.post("/camera/anteprima", json={"source": "camera:folder"})
|
||||
assert r.status_code == 200
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert img.shape[:2] == (48, 64) # lato_max 1280 > 64: resta com'è
|
||||
assert r.headers["x-nitidezza"] == "0.0" # grigio uniforme
|
||||
assert r.headers["x-saturati-pct"] == "0.00"
|
||||
assert r.headers["x-media"] == "128.0"
|
||||
|
||||
|
||||
def test_anteprima_parametri_errati_400(client):
|
||||
for corpo in ({}, {"source": SORGENTE, "lato_max": 100}, {"source": SORGENTE, "lato_max": 5000},
|
||||
{"source": SORGENTE, "timeout_ms": 0}, {"source": SORGENTE, "grezzo": "si"}):
|
||||
assert client.post("/camera/anteprima", json=corpo).status_code == 400, corpo
|
||||
|
||||
|
||||
def test_anteprima_sorgente_inesistente_404(client_cartella):
|
||||
assert client_cartella.post("/camera/anteprima",
|
||||
json={"source": "camera:non-esiste"}).status_code == 404
|
||||
|
||||
|
||||
def test_anteprima_errore_camera_503_e_timeout_504(client):
|
||||
from visionsuite.camera.camera_interface import CameraGrabError
|
||||
|
||||
def rotta(timeout=0):
|
||||
raise CameraGrabError("Camera is not open")
|
||||
|
||||
client.finta.grab = rotta
|
||||
r = client.post("/camera/anteprima", json={"source": SORGENTE})
|
||||
assert r.status_code == 503 and r.json()["detail"] == "La camera non è aperta"
|
||||
|
||||
def lenta(timeout=0):
|
||||
raise TimeoutError("scaduto")
|
||||
|
||||
client.finta.grab = lenta
|
||||
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 504
|
||||
|
||||
|
||||
# ---- parametri
|
||||
|
||||
def test_parametri_regolabile(client):
|
||||
r = client.get("/camera/parametri", params={"source": SORGENTE})
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j["source"] == SORGENTE and j["regolabile"] is True
|
||||
assert list(j["parametri"]) == ["esposizione", "esposizione_auto", "guadagno", "guadagno_auto"]
|
||||
assert j["parametri"]["esposizione"]["valore"] == 5000.0
|
||||
assert j["parametri"]["esposizione_auto"]["scelte"] == ["Off", "Once", "Continuous"]
|
||||
|
||||
|
||||
def test_parametri_solo_quelli_supportati(cartella, tmp_path, monkeypatch):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(supportati=["esposizione"])) as c:
|
||||
j = c.get("/camera/parametri", params={"source": SORGENTE}).json()
|
||||
assert j["regolabile"] is True and list(j["parametri"]) == ["esposizione"]
|
||||
|
||||
|
||||
def test_parametri_sorgente_non_regolabile(client_cartella):
|
||||
j = client_cartella.get("/camera/parametri", params={"source": "camera:folder"}).json()
|
||||
assert j == {"source": "camera:folder", "regolabile": False, "parametri": {}}
|
||||
|
||||
|
||||
def test_parametri_senza_source_400(client):
|
||||
assert client.get("/camera/parametri").status_code == 400
|
||||
|
||||
|
||||
def test_put_scrive_prima_gli_auto(client):
|
||||
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {
|
||||
"esposizione": 8000, "guadagno": 3.5, "esposizione_auto": "Off", "guadagno_auto": "Off"}})
|
||||
assert r.status_code == 200
|
||||
assert client.finta.scritture == [
|
||||
("esposizione_auto", "Off", 3), ("guadagno_auto", "Off", 3),
|
||||
("esposizione", 8000, 3), ("guadagno", 3.5, 3)]
|
||||
j = r.json() # valori riletti dalla camera
|
||||
assert j["parametri"]["esposizione"]["valore"] == 8000
|
||||
assert j["parametri"]["esposizione_auto"]["valore"] == "Off"
|
||||
|
||||
|
||||
def test_put_nome_ignoto_400(client):
|
||||
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {"frame_rate": 10}})
|
||||
assert r.status_code == 400
|
||||
assert client.finta.scritture == []
|
||||
for corpo in ({"source": SORGENTE}, {"source": SORGENTE, "valori": {}},
|
||||
{"source": SORGENTE, "valori": {"guadagno": True}}):
|
||||
assert client.put("/camera/parametri", json=corpo).status_code == 400, corpo
|
||||
|
||||
|
||||
def test_put_non_regolabile_409(client_cartella):
|
||||
r = client_cartella.put("/camera/parametri",
|
||||
json={"source": "camera:folder", "valori": {"esposizione": 1000}})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "questa sorgente non ha parametri regolabili"
|
||||
|
||||
|
||||
def test_put_rifiuto_della_camera_422(cartella, tmp_path, monkeypatch):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(rifiuta=["guadagno"])) as c:
|
||||
r = c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"guadagno": 5}})
|
||||
assert r.status_code == 422
|
||||
assert "guadagno" in r.json()["detail"]
|
||||
|
||||
|
||||
# ---- salvataggio e riapertura
|
||||
|
||||
def test_salva_crea_il_file(client):
|
||||
r = client.post("/camera/parametri/salva", json={"source": SORGENTE})
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j["source"] == SORGENTE and j["salvati"] is True
|
||||
atteso = client.config / "settaggi" / j["file"]
|
||||
assert atteso.is_file()
|
||||
assert j["file"].startswith("camera_finta-") and j["file"].endswith(".json")
|
||||
assert json.loads(atteso.read_text(encoding="utf-8"))["esposizione"] == 5000.0
|
||||
|
||||
|
||||
def test_salva_non_regolabile_409(client_cartella):
|
||||
r = client_cartella.post("/camera/parametri/salva", json={"source": "camera:folder"})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_riapertura_carica_i_settaggi(cartella, tmp_path, monkeypatch):
|
||||
config = tmp_path / "cfg"
|
||||
prima = Finta()
|
||||
with _client(cartella, config, monkeypatch, prima) as c:
|
||||
c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"esposizione": 7777}})
|
||||
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
|
||||
seconda = Finta() # servizio riavviato: camera nuova, valori di fabbrica
|
||||
with _client(cartella, config, monkeypatch, seconda) as c:
|
||||
assert seconda.caricato is None # niente apertura finché nessuno la chiede
|
||||
c.get("/camera/parametri", params={"source": SORGENTE})
|
||||
assert seconda.caricato == config / "settaggi" / file
|
||||
assert seconda.valori["esposizione"] == 7777
|
||||
|
||||
|
||||
def test_settaggi_rotti_non_bloccano_l_apertura(cartella, tmp_path, monkeypatch):
|
||||
class Rotta(Finta):
|
||||
def carica_settaggi(self, percorso, forza=False):
|
||||
raise ValueError("file illeggibile")
|
||||
|
||||
config = tmp_path / "cfg"
|
||||
with _client(cartella, config, monkeypatch, Finta()) as c:
|
||||
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
|
||||
assert (config / "settaggi" / file).is_file()
|
||||
with _client(cartella, config, monkeypatch, Rotta()) as c:
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
|
||||
|
||||
def test_apertura_chiede_il_frame_piu_recente(cartella, tmp_path, monkeypatch):
|
||||
# #89: solo_ultimo_frame() una volta sola all'apertura, non a ogni richiesta
|
||||
class ConUltimoFrame(Finta):
|
||||
chiamate = 0
|
||||
|
||||
def solo_ultimo_frame(self, attivo=True):
|
||||
self.chiamate += 1
|
||||
|
||||
finta = ConUltimoFrame()
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
|
||||
for _ in range(3):
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
assert finta.chiamate == 1
|
||||
|
||||
|
||||
def test_frame_piu_recente_non_impostabile_non_blocca_l_apertura(cartella, tmp_path, monkeypatch, caplog):
|
||||
# #89: producer senza il nodo -> avviso nel log, apertura riuscita
|
||||
class SenzaNodo(Finta):
|
||||
def solo_ultimo_frame(self, attivo=True):
|
||||
raise CameraStateError("questo producer non espone StreamBufferHandlingMode")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, SenzaNodo()) as c:
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
assert "frame piu' recente non impostabile" in caplog.text
|
||||
|
||||
|
||||
def test_sorgente_senza_solo_ultimo_frame_si_apre(cartella, tmp_path, monkeypatch):
|
||||
# #89: FolderCamera e simili non hanno il metodo: nessun errore
|
||||
assert not hasattr(Finta(), "solo_ultimo_frame")
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta()) as c:
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
|
||||
|
||||
# ---- pagina
|
||||
|
||||
def test_acquisizione_alias_della_pagina(client):
|
||||
a = client.get("/acquisizione")
|
||||
c = client.get("/calibrazione")
|
||||
assert a.status_code == 200 and a.text == c.text
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||
_spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
PAGINA = _mod.PAGINA
|
||||
|
||||
|
||||
def test_titolo_e_stile_scuro():
|
||||
assert "<title>Acquisizione camera</title>" in PAGINA
|
||||
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
|
||||
|
||||
|
||||
def test_rotte_usate():
|
||||
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
|
||||
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
|
||||
assert '"' + rotta in PAGINA, rotta
|
||||
|
||||
|
||||
def test_offline_e_senza_finestre_del_browser():
|
||||
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||
assert vietato not in PAGINA, vietato
|
||||
|
||||
|
||||
def test_id_principali():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
|
||||
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
|
||||
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
|
||||
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
|
||||
assert attesi <= ids, attesi - ids
|
||||
# ogni id usato dal JS con $("...") esiste nel markup
|
||||
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||
assert usati <= ids, usati - ids
|
||||
|
||||
|
||||
def test_sezioni_e_testi_italiani():
|
||||
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
|
||||
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
|
||||
"Mostra immagine calibrata"): # #92
|
||||
assert testo in PAGINA, testo
|
||||
assert "it-IT" in PAGINA # numeri con la virgola
|
||||
assert "Choose File" not in PAGINA
|
||||
|
||||
|
||||
def test_localstorage_protetto():
|
||||
# ogni uso di localStorage sta dentro un try
|
||||
for m in re.finditer(r"localStorage[.]", PAGINA):
|
||||
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Test della pagina a due tab (#91, #92): contenuto statico di PAGINA, senza servizio."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||
_spec = importlib.util.spec_from_file_location("calibrazione_pagina_tab", _FILE)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
PAGINA = _mod.PAGINA
|
||||
|
||||
|
||||
def test_due_tab_con_ruolo_e_selezione():
|
||||
assert re.search(r'<button id="tabAcq"[^>]*role="tab"[^>]*aria-selected="true"', PAGINA)
|
||||
assert re.search(r'<button id="tabCal"[^>]*role="tab"[^>]*aria-selected="false"', PAGINA)
|
||||
assert ">Acquisizione</button>" in PAGINA and ">Calibrazione</button>" in PAGINA
|
||||
assert 'role="tablist"' in PAGINA and 'role="tabpanel"' in PAGINA
|
||||
assert 'aria-selected", cal ?' in PAGINA # la selezione cambia da JS
|
||||
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
|
||||
|
||||
|
||||
def test_sorgente_in_cima_poi_riquadri():
|
||||
# #92: Sorgente, anteprima e parametri stanno nel pannello Acquisizione; Sorgente prima dei riquadri
|
||||
i_acq = PAGINA.index('id="pannelloAcq"')
|
||||
i_cal = PAGINA.index('id="pannelloCal"')
|
||||
i_sorg = PAGINA.index('id="sezioneSorgente"')
|
||||
i_ant = PAGINA.index('id="sezioneAnteprima"')
|
||||
i_riq = PAGINA.index('id="riquadri"')
|
||||
assert i_acq < i_sorg < i_ant < i_riq < PAGINA.index('id="sezioneParametri"') < i_cal
|
||||
assert PAGINA.index('id="sezioneCalibrazione"') > i_cal
|
||||
# barra: casella calibrata e mirino; la vecchia casella «Immagine grezza» non c'e' piu'
|
||||
assert 'id="mostraCal"' in PAGINA and "Mostra immagine calibrata" in PAGINA
|
||||
assert 'id="grezzo"' not in PAGINA and "Immagine grezza (senza correzione)" not in PAGINA
|
||||
# riquadri: «Immagine» sempre visibile, «Calibrata» nascosta finche' la casella e' spenta
|
||||
assert re.search(r'<div class="riquadro" id="riquadroCal" hidden>', PAGINA)
|
||||
assert "grezzo: true" in PAGINA
|
||||
|
||||
|
||||
def test_id_del_pannello_calibrazione():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
attesi = {"calSorgente", "quadrato", "acquisisci", "nomeCal", "salva", "msgSalva", "calibrazioni",
|
||||
"msgElenco", "stato", "risultato", "overlay",
|
||||
"caricaImmagine", "fileImmagine", "rimuoviImmagine", "riquadroCal", "immagineCal", "mirinoCal"}
|
||||
assert attesi <= ids, attesi - ids
|
||||
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||
assert usati <= ids, usati - ids
|
||||
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio
|
||||
|
||||
|
||||
def test_calibrazione_senza_file_ne_radio():
|
||||
# #92: l'immagine da file e' una sorgente di acquisizione, non un'opzione della calibrazione
|
||||
for sparito in ("origFile", "origSorgente", "scegliFile", "nomeFile", 'type="radio"', "Immagine da file", "Sorgente selezionata"):
|
||||
assert sparito not in PAGINA, sparito
|
||||
# l'unico input file e' nella sezione Sorgente, in cima alla tab Acquisizione
|
||||
assert PAGINA.count('type="file"') == 1
|
||||
assert PAGINA.index('type="file"') < PAGINA.index('id="pannelloCal"')
|
||||
assert "immagine grezza dalla tab Acquisizione" in PAGINA
|
||||
# il calcolo manda solo la sorgente (e il lato), mai il file
|
||||
assert 'f.append("source", sorgente)' in PAGINA and 'f.append("image"' not in PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||
|
||||
|
||||
def test_testi_del_pannello():
|
||||
for testo in ("Camera o immagine", "Carica immagine da file...", "Rimuovi immagine", "Calcola",
|
||||
"Calibrazioni salvate", "risoluzione diversa", "Calibrata",
|
||||
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome",
|
||||
"calibrata: ", "non calibrata"):
|
||||
assert testo in PAGINA, testo
|
||||
|
||||
|
||||
def test_rotte_del_contratto():
|
||||
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate",
|
||||
"/camera/calibration?source=", "/camera/immagini", "/camera/immagini?nome=",
|
||||
"/camera/anteprima/calibrata"): # #92
|
||||
assert '"' + rotta in PAGINA, rotta
|
||||
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
|
||||
assert parola in PAGINA, parola
|
||||
# il calcolo non salva piu': il campo save sparisce
|
||||
assert 'append("save"' not in PAGINA
|
||||
|
||||
|
||||
def test_nessuna_finestra_del_browser_e_offline():
|
||||
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||
assert vietato not in PAGINA, vietato
|
||||
|
||||
|
||||
def test_acquisizione_di_87_90_conservata():
|
||||
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
|
||||
for parola in ("Parametri camera", "Mirino centrale",
|
||||
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
|
||||
assert parola in PAGINA, parola
|
||||
|
||||
|
||||
def test_pagina_stretta():
|
||||
assert "@media (max-width: 500px)" in PAGINA
|
||||
# #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale
|
||||
assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA
|
||||
assert "Lettura dei parametri..." in PAGINA
|
||||
|
||||
|
||||
def test_ciclo_sospeso_nella_tab_calibrazione():
|
||||
# #92: entrando in Calibrazione il ciclo si ferma e poi riprende; Calcola aspetta le richieste in volo
|
||||
assert "A.riprendi = true; fermaAnteprima();" in PAGINA
|
||||
assert "if (!cal && A.riprendi)" in PAGINA
|
||||
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||
assert "fermaAnteprima();" in calcola and "while (A.inVolo)" in calcola
|
||||
|
||||
|
||||
def test_calibrata_dopo_anteprima_e_dopo_carica():
|
||||
# #92: la calibrata segue ogni anteprima grezza riuscita e si aggiorna dopo Carica/Scollega; 404/409 al posto dell'immagine
|
||||
assert "await aggiornaCalibrata(lato);" in PAGINA
|
||||
assert PAGINA.count("aggiornaCalibrata();") >= 3
|
||||
assert "messaggioCalibrata(await dettaglio(r))" in PAGINA
|
||||
assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA
|
||||
@@ -1,10 +1,18 @@
|
||||
"""API Key authentication dependency for FastAPI."""
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services.auth_service import hash_api_key
|
||||
from src.backend.utils.tempo import utcnow
|
||||
|
||||
_RENEW_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
@@ -24,15 +32,25 @@ async def get_current_user(
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.api_key == api_key, User.active == True)
|
||||
select(ApiKey, User)
|
||||
.join(User, User.id == ApiKey.user_id)
|
||||
.where(ApiKey.key_hash == hash_api_key(api_key), User.active == True)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
row = result.first()
|
||||
now = utcnow()
|
||||
|
||||
if user is None:
|
||||
if row is None or row[0].expires_at <= now:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or inactive API key",
|
||||
detail="Invalid or expired API key",
|
||||
)
|
||||
key_row, user = row
|
||||
|
||||
# Sliding TTL, written at most once per _RENEW_INTERVAL to spare the DB.
|
||||
if key_row.last_used_at is None or now - key_row.last_used_at > _RENEW_INTERVAL:
|
||||
key_row.last_used_at = now
|
||||
key_row.expires_at = now + timedelta(hours=settings.api_key_ttl_hours)
|
||||
await db.flush()
|
||||
|
||||
# Store user_id in request state for access logging middleware
|
||||
request.state.user_id = user.id
|
||||
|
||||
@@ -6,9 +6,14 @@ from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from sqlalchemy import insert
|
||||
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
from src.backend.database import async_session_factory
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
|
||||
# AccessLog.action is String(100): a long path (with query-string-like
|
||||
# segments) silently failed the insert before this truncation existed.
|
||||
_ACTION_MAX_LENGTH = 100
|
||||
|
||||
|
||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that logs every API request to the access_logs table."""
|
||||
@@ -30,11 +35,12 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# Log asynchronously (don't block response)
|
||||
try:
|
||||
action = f"{request.method} {request.url.path}"[:_ACTION_MAX_LENGTH]
|
||||
async with async_session_factory() as session:
|
||||
await session.execute(
|
||||
insert(AccessLog).values(
|
||||
user_id=user_id,
|
||||
action=f"{request.method} {request.url.path}",
|
||||
action=action,
|
||||
details={
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
@@ -42,7 +48,7 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": round(duration_ms, 2),
|
||||
},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
ip_address=RateLimitMiddleware._client_ip(request),
|
||||
user_agent=request.headers.get("user-agent", "")[:500],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Rate limiting middleware for FastAPI.
|
||||
|
||||
Implements in-memory sliding window rate limiting per client IP.
|
||||
Configurable limits for login and general endpoints.
|
||||
Configurable limits for login and endpoints that carry credentials, and for
|
||||
everything else.
|
||||
"""
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Callable
|
||||
@@ -17,14 +19,34 @@ from src.backend.config import settings
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that enforces per-IP rate limits using a sliding window.
|
||||
|
||||
- Login endpoint (/api/auth/login): limited to `rate_limit_login` req/min.
|
||||
- Login bucket: `/api/auth/login`, the paths that carry a capoturno's
|
||||
credentials (`/api/measurements/*/authorise`,
|
||||
`/api/production-runs/{id}/pause|resume|close`), and everything under
|
||||
`/api/setup/` (the setup password). Limited to `rate_limit_login`
|
||||
req/min. Guessing a supervisor's or the setup password is the same
|
||||
attack as guessing a login password, just aimed at a different endpoint.
|
||||
- All other endpoints: limited to `rate_limit_general` req/min.
|
||||
Returns HTTP 429 with Retry-After header when limit exceeded.
|
||||
|
||||
Both configured limits are divided across worker processes before being
|
||||
applied (see `_effective_limit`): each bucket lives in this process's
|
||||
memory, so with `--workers N` a single configured limit is enforced N
|
||||
times over across the cluster unless it is divided down first.
|
||||
"""
|
||||
|
||||
LOGIN_PATH = "/api/auth/login"
|
||||
# #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
|
||||
# body. Matched by pattern, not equality, because the id in the middle
|
||||
# varies per request.
|
||||
_SUPERVISOR_CREDENTIAL_PATTERNS = (
|
||||
re.compile(r"^/api/measurements/\d+/authorise$"),
|
||||
re.compile(r"^/api/production-runs/\d+/(?:pause|resume|close)$"),
|
||||
)
|
||||
|
||||
def __init__(self, app) -> None:
|
||||
super().__init__(app)
|
||||
# {ip: [timestamp, ...]} per bucket
|
||||
@@ -34,23 +56,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
@staticmethod
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Resolve the originating client IP, honoring proxy headers.
|
||||
"""Resolve the originating client IP behind `trusted_proxy_count` proxies.
|
||||
|
||||
Order of precedence: ``X-Forwarded-For`` (first hop), ``X-Real-IP``,
|
||||
``request.client.host``. Required because Nginx and the Flask client
|
||||
sit between the tablet and the API; without parsing these headers
|
||||
every tablet shares one bucket.
|
||||
Nginx (and, behind it, the Flask client) each add one hop to
|
||||
X-Forwarded-For. The *leftmost* entries are whatever the original
|
||||
caller claimed and are trivially spoofed; trust only extends to the
|
||||
hops actually appended by our own trusted proxies, counted from the
|
||||
*right*. With `trusted_proxy_count = N`, the client IP is the Nth
|
||||
element from the end of XFF (the one added by the outermost trusted
|
||||
proxy). If XFF has fewer elements than that, or is absent, fall back
|
||||
to `request.client.host` - except that `X-Real-IP` is tried first when
|
||||
XFF is simply missing and at least one proxy is trusted.
|
||||
|
||||
With `trusted_proxy_count = 0`, no proxy header is honored at all
|
||||
(neither XFF nor X-Real-IP) and only `request.client.host` is used.
|
||||
"""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
first = xff.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
real = request.headers.get("x-real-ip")
|
||||
if real:
|
||||
return real.strip()
|
||||
count = settings.trusted_proxy_count
|
||||
if count > 0:
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
hops = [h.strip() for h in xff.split(",") if h.strip()]
|
||||
if len(hops) >= count:
|
||||
return hops[-count]
|
||||
# Fewer hops than trusted proxies: the chain is short of what
|
||||
# was configured, so fall back rather than trust an
|
||||
# under-populated header.
|
||||
else:
|
||||
real = request.headers.get("x-real-ip")
|
||||
if real:
|
||||
return real.strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
@classmethod
|
||||
def _is_login_bucket_request(cls, method: str, path: str) -> bool:
|
||||
"""Whether this request counts against the login bucket rather than general.
|
||||
|
||||
`/api/setup/*` is checked regardless of method: `GET /api/setup/status`
|
||||
now accepts an optional password (see setup.py), so guessing it there
|
||||
is the same attack as guessing it on any of the POST endpoints.
|
||||
"""
|
||||
if path.startswith("/api/setup/"):
|
||||
return True
|
||||
if method != "POST":
|
||||
return False
|
||||
if path in (cls.LOGIN_PATH, cls.LOGIN_PIN_PATH):
|
||||
return True
|
||||
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
|
||||
|
||||
@staticmethod
|
||||
def _effective_limit(configured: int) -> int:
|
||||
"""Divide a configured per-minute limit across worker processes.
|
||||
|
||||
Rounds up and never returns less than 1, so a low configured limit
|
||||
with high concurrency still allows at least one request per worker
|
||||
per window rather than locking everyone out.
|
||||
"""
|
||||
concurrency = max(settings.web_concurrency, 1)
|
||||
return max(1, -(-configured // concurrency))
|
||||
|
||||
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
||||
"""Remove timestamps outside the current sliding window."""
|
||||
cutoff = now - self.WINDOW_SECONDS
|
||||
@@ -97,10 +160,12 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
self._evict_stale_ips(self._login_requests, now)
|
||||
self._evict_stale_ips(self._general_requests, now)
|
||||
|
||||
# Check login-specific rate limit
|
||||
if path == self.LOGIN_PATH and request.method == "POST":
|
||||
# Check login-bucket rate limit (login itself, supervisor-credential
|
||||
# paths, and the setup password)
|
||||
if self._is_login_bucket_request(request.method, path):
|
||||
allowed, retry_after = self._check_rate_limit(
|
||||
self._login_requests, client_ip, settings.rate_limit_login, now
|
||||
self._login_requests, client_ip,
|
||||
self._effective_limit(settings.rate_limit_login), now,
|
||||
)
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
@@ -111,7 +176,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# Check general rate limit
|
||||
allowed, retry_after = self._check_rate_limit(
|
||||
self._general_requests, client_ip, settings.rate_limit_general, now
|
||||
self._general_requests, client_ip,
|
||||
self._effective_limit(settings.rate_limit_general), now,
|
||||
)
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Authentication router - login, logout, profile."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from 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,13 +40,47 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pin-users", response_model=list[PinUserItem])
|
||||
async def pin_users(db: AsyncSession = Depends(get_db)):
|
||||
"""Utenti che possono entrare con PIN (#59): attivi, non admin, con PIN.
|
||||
|
||||
Pubblico per costruzione (serve prima del login): espone solo id e nome.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.active == True, # noqa: E712
|
||||
User.is_admin == False, # noqa: E712
|
||||
User.pin_hash.is_not(None),
|
||||
)
|
||||
)
|
||||
items = [
|
||||
PinUserItem(id=u.id, display_name=u.display_name or u.username)
|
||||
for u in result.scalars().all()
|
||||
]
|
||||
items.sort(key=lambda i: i.display_name.casefold())
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/login-pin", response_model=LoginResponse)
|
||||
async def login_pin(data: PinLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Login con user_id e PIN a 4 cifre (#59); stessa risposta del login."""
|
||||
user = await authenticate_pin(db, data.user_id, data.pin)
|
||||
api_key = await login_user(db, user)
|
||||
return LoginResponse(user=UserResponse.model_validate(user), api_key=api_key)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
request: Request,
|
||||
all_sessions: bool = False,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Logout - invalidate API key."""
|
||||
await logout_user(db, user)
|
||||
"""Logout. Deletes this session's API key only; ?all_sessions=true
|
||||
deletes every key of the user."""
|
||||
await logout_user(
|
||||
db, user, key=request.headers.get("X-API-Key"), all_sessions=all_sessions
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
|
||||
@@ -6,9 +6,15 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import get_current_user, require_maker
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,6 +26,93 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
ALLOWED_PDF_TYPE = "application/pdf"
|
||||
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
|
||||
|
||||
# Magic-byte signatures: the client-declared Content-Type is only what the
|
||||
# browser claimed and cannot be trusted (#36 point 2 — a file named "x.html"
|
||||
# declared image/png was stored and then served by extension as text/html,
|
||||
# which a browser executes). SVG is intentionally not recognised: it is not
|
||||
# in ALLOWED_TYPES and can itself carry script content.
|
||||
_MAGIC_SIGNATURES: list[tuple[bytes, str, str]] = [
|
||||
# (signature, mime type, canonical extension)
|
||||
(b"\x89PNG\r\n\x1a\n", "image/png", ".png"),
|
||||
(b"\xff\xd8\xff", "image/jpeg", ".jpg"),
|
||||
(b"GIF87a", "image/gif", ".gif"),
|
||||
(b"GIF89a", "image/gif", ".gif"),
|
||||
(b"%PDF-", "application/pdf", ".pdf"),
|
||||
]
|
||||
|
||||
# Read uploads in chunks so a declared-huge body is rejected as soon as it
|
||||
# crosses the limit, instead of being buffered into memory in full first.
|
||||
_UPLOAD_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
def detect_file_type(content: bytes) -> tuple[str, str] | None:
|
||||
"""Identify a file's real type from its magic bytes.
|
||||
|
||||
Returns (mime_type, canonical_extension), or None when the content does
|
||||
not match any type this system serves.
|
||||
"""
|
||||
for signature, mime, ext in _MAGIC_SIGNATURES:
|
||||
if content.startswith(signature):
|
||||
return mime, ext
|
||||
# WebP: "RIFF" + 4-byte little-endian size + "WEBP"; the size varies.
|
||||
if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
|
||||
return "image/webp", ".webp"
|
||||
return None
|
||||
|
||||
# Top-level folders nothing may DELETE through this router: statistics/reports are
|
||||
# audit records, the logo is only ever replaced via POST /api/settings/logo.
|
||||
# task_results/: allegati degli esiti dei task (#55), record di audit.
|
||||
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/", "task_results/")
|
||||
|
||||
# GET under these needs a role that is meant to see production data, not just
|
||||
# any authenticated user (a Maker or MeasurementTec has no business reading
|
||||
# every production's statistics/report file by guessing its name).
|
||||
_RESTRICTED_GET_PREFIXES = ("statistics/", "reports/")
|
||||
|
||||
|
||||
def _can_read_restricted(user: User) -> bool:
|
||||
return user.is_admin or user.has_role("Metrologist") or user.has_role("Supervisor")
|
||||
|
||||
|
||||
async def _is_recipe_file_protected(db: AsyncSession, relative_path: str) -> bool:
|
||||
"""True when `relative_path` (uploads-relative, forward slashes) must not be
|
||||
deleted: it is a recipe's cover image, or a task/subtask file belonging to a
|
||||
version that is either not the current one (kept for history) or already has
|
||||
measurements against it (the audit trail needs it to make sense of them).
|
||||
|
||||
A path not referenced anywhere is safe to delete (orphaned upload).
|
||||
"""
|
||||
recipe_hit = (await db.execute(
|
||||
select(Recipe.id).where(Recipe.image_path == relative_path)
|
||||
)).scalar_one_or_none()
|
||||
if recipe_hit is not None:
|
||||
return True
|
||||
|
||||
version_id = (await db.execute(
|
||||
select(RecipeTask.version_id).where(RecipeTask.file_path == relative_path)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if version_id is None:
|
||||
version_id = (await db.execute(
|
||||
select(RecipeTask.version_id)
|
||||
.join(RecipeSubtask, RecipeSubtask.task_id == RecipeTask.id)
|
||||
.where(RecipeSubtask.image_path == relative_path)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if version_id is None:
|
||||
return False
|
||||
|
||||
version = (await db.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.id == version_id)
|
||||
)).scalar_one_or_none()
|
||||
if version is None or not version.is_current:
|
||||
return True
|
||||
|
||||
has_measurements = (await db.execute(
|
||||
select(Measurement.id).where(Measurement.version_id == version_id).limit(1)
|
||||
)).scalar_one_or_none() is not None
|
||||
return has_measurements
|
||||
|
||||
|
||||
def validate_file_type(content_type: str) -> bool:
|
||||
"""Validate if file type is allowed."""
|
||||
@@ -119,25 +212,36 @@ async def upload_file(
|
||||
"""Upload an image or PDF file.
|
||||
|
||||
Files are stored in uploads/{recipe_id}/{version_id}/
|
||||
Thumbnails are generated for images.
|
||||
Thumbnails are generated for images. The type is authoritatively
|
||||
determined from the file's magic bytes, not the client-declared
|
||||
Content-Type (#36 point 2): the stored extension always matches what was
|
||||
actually detected, so it can never be served back as something else.
|
||||
"""
|
||||
# Validate file type
|
||||
if not validate_file_type(file.content_type):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {file.content_type} not allowed. Must be image or PDF.",
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
# Read in chunks, stopping as soon as the size limit is crossed instead
|
||||
# of buffering an oversized body into memory first.
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
buffer = bytearray()
|
||||
while True:
|
||||
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
)
|
||||
content = bytes(buffer)
|
||||
file_size = len(content)
|
||||
|
||||
# Validate file size
|
||||
if not validate_file_size(file_size):
|
||||
# Validate file type from the actual bytes, not file.content_type
|
||||
detected = detect_file_type(content)
|
||||
if detected is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
detail="File type not allowed. Must be a recognised image or PDF.",
|
||||
)
|
||||
detected_mime, detected_ext = detected
|
||||
|
||||
# Determine storage path
|
||||
if recipe_id is not None and version_id is not None:
|
||||
@@ -147,18 +251,17 @@ async def upload_file(
|
||||
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Sanitize the uploaded filename
|
||||
# Sanitize the uploaded filename, then force its extension to the one
|
||||
# actually detected: a declared/renamed extension is not trusted either.
|
||||
safe_filename = sanitize_filename(file.filename or "")
|
||||
base_name = Path(safe_filename).stem or "file"
|
||||
safe_filename = f"{base_name}{detected_ext}"
|
||||
|
||||
# Generate unique filename if file already exists
|
||||
file_path = storage_dir / safe_filename
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
name_parts = safe_filename.rsplit(".", 1)
|
||||
if len(name_parts) == 2:
|
||||
file_path = storage_dir / f"{name_parts[0]}_{counter}.{name_parts[1]}"
|
||||
else:
|
||||
file_path = storage_dir / f"{safe_filename}_{counter}"
|
||||
file_path = storage_dir / f"{base_name}_{counter}{detected_ext}"
|
||||
counter += 1
|
||||
|
||||
# Write file
|
||||
@@ -166,7 +269,7 @@ async def upload_file(
|
||||
|
||||
# Generate thumbnail for images
|
||||
thumbnail_path = None
|
||||
if file.content_type in ALLOWED_IMAGE_TYPES:
|
||||
if detected_mime in ALLOWED_IMAGE_TYPES:
|
||||
thumbnail_dir = storage_dir / "thumbnails"
|
||||
thumbnail_dir.mkdir(exist_ok=True)
|
||||
thumbnail_path = thumbnail_dir / file_path.name
|
||||
@@ -183,7 +286,7 @@ async def upload_file(
|
||||
return {
|
||||
"file_path": str(relative_path).replace("\\", "/"),
|
||||
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
|
||||
"file_type": file.content_type,
|
||||
"file_type": detected_mime,
|
||||
"file_size": file_size,
|
||||
}
|
||||
|
||||
@@ -195,8 +298,14 @@ async def get_file(
|
||||
):
|
||||
"""Serve a file from the uploads directory.
|
||||
|
||||
Requires authentication but no specific role.
|
||||
Requires authentication; statistics/ and reports/ additionally require
|
||||
Metrologist, Supervisor or admin, since those are production data.
|
||||
"""
|
||||
if file_path.startswith(_RESTRICTED_GET_PREFIXES) and not _can_read_restricted(user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Metrologist, Supervisor or admin role required",
|
||||
)
|
||||
full_path = resolve_upload_path(file_path)
|
||||
return FileResponse(full_path)
|
||||
|
||||
@@ -205,13 +314,29 @@ async def get_file(
|
||||
async def delete_file(
|
||||
file_path: str,
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a file from the uploads directory.
|
||||
|
||||
Also deletes associated thumbnail if it exists.
|
||||
Also deletes associated thumbnail if it exists. statistics/, reports/ and
|
||||
logos/ can never be deleted here (403); a recipe file still referenced by
|
||||
its current version or by a version with measurements is kept (409).
|
||||
"""
|
||||
if file_path.startswith(_UNDELETABLE_PREFIXES):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="This folder cannot be deleted through the files API.",
|
||||
)
|
||||
|
||||
full_path = resolve_upload_path(file_path)
|
||||
|
||||
relative_path = str(full_path.relative_to(settings.upload_path)).replace("\\", "/")
|
||||
if await _is_recipe_file_protected(db, relative_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="File is referenced by a current or measured recipe version.",
|
||||
)
|
||||
|
||||
# Delete thumbnail if it exists
|
||||
if full_path.parent.name != "thumbnails":
|
||||
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcNaiveInput
|
||||
from src.backend.utils.tempo import as_utc_naive, iso_utc, to_local, utcnow
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -19,6 +21,7 @@ from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.measurement import (
|
||||
CurrentCycleResponse,
|
||||
MeasurementAuthorisation,
|
||||
MeasurementBatchCreate,
|
||||
MeasurementCreate,
|
||||
@@ -28,6 +31,7 @@ from src.backend.models.api.measurement import (
|
||||
)
|
||||
from src.backend.services import auth_service, measurement_service
|
||||
from src.backend.services.measurement_service import save_measurement
|
||||
from src.backend.services.production_export_service import neutralise_csv_cell
|
||||
|
||||
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||
|
||||
@@ -96,6 +100,24 @@ async def get_task_progress(
|
||||
return TaskProgressListResponse(tasks=tasks)
|
||||
|
||||
|
||||
@router.get("/current-cycle", response_model=CurrentCycleResponse)
|
||||
async def get_current_cycle(
|
||||
production_run_id: int = Query(..., gt=0),
|
||||
task_id: int = Query(..., gt=0),
|
||||
user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Quotes of this task already measured in the cycle under way.
|
||||
|
||||
Asked on load, so that a reload mid-cycle does not present quotes again that
|
||||
were taken a moment ago.
|
||||
"""
|
||||
rows = await measurement_service.current_cycle_measurements(
|
||||
db, production_run_id, task_id,
|
||||
)
|
||||
return CurrentCycleResponse(measurements=rows)
|
||||
|
||||
|
||||
@router.post("/{measurement_id}/authorise", response_model=MeasurementResponse)
|
||||
async def authorise_measurement(
|
||||
measurement_id: int,
|
||||
@@ -125,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,
|
||||
@@ -173,6 +196,7 @@ def _build_measurement_filters(
|
||||
date_from: datetime | None,
|
||||
date_to: datetime | None,
|
||||
pass_fail: str | None,
|
||||
production_run_id: int | None = None,
|
||||
) -> list:
|
||||
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
|
||||
filters = []
|
||||
@@ -198,6 +222,8 @@ def _build_measurement_filters(
|
||||
filters.append(Measurement.measured_at <= date_to)
|
||||
if pass_fail is not None:
|
||||
filters.append(Measurement.pass_fail == pass_fail)
|
||||
if production_run_id is not None:
|
||||
filters.append(Measurement.production_run_id == production_run_id)
|
||||
return filters
|
||||
|
||||
|
||||
@@ -209,9 +235,10 @@ async def get_measurements(
|
||||
measured_by: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
production_run_id: int | None = Query(None, gt=0),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=500),
|
||||
user: User = Depends(get_current_user),
|
||||
@@ -227,7 +254,7 @@ async def get_measurements(
|
||||
"""
|
||||
filters = _build_measurement_filters(
|
||||
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||
serial_number, date_from, date_to, pass_fail,
|
||||
serial_number, date_from, date_to, pass_fail, production_run_id,
|
||||
)
|
||||
|
||||
# Build query
|
||||
@@ -266,15 +293,18 @@ async def export_measurements_csv(
|
||||
measured_by: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Export measurements to CSV with configurable delimiter and decimal separator."""
|
||||
# Check user has MeasurementTec or Metrologist role
|
||||
if not (user.has_role("MeasurementTec") or user.has_role("Metrologist")):
|
||||
# Check user has MeasurementTec or Metrologist role, same as require_role: an
|
||||
# admin with no explicit role assigned is still let through (#36 point 3).
|
||||
if not (
|
||||
user.has_role("MeasurementTec") or user.has_role("Metrologist") or user.is_admin
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="MeasurementTec or Metrologist role required",
|
||||
@@ -342,10 +372,10 @@ async def export_measurements_csv(
|
||||
value_str,
|
||||
m.pass_fail,
|
||||
deviation_str,
|
||||
m.lot_number or "",
|
||||
m.serial_number or "",
|
||||
neutralise_csv_cell(m.lot_number or ""),
|
||||
neutralise_csv_cell(m.serial_number or ""),
|
||||
m.input_method,
|
||||
m.measured_at.isoformat(),
|
||||
iso_utc(m.measured_at),
|
||||
m.synced_to_csv,
|
||||
])
|
||||
|
||||
@@ -355,6 +385,6 @@ async def export_measurements_csv(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
"Content-Disposition": f"attachment; filename=measurements_{to_local(utcnow()).strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ Deliberately stateless: nothing lives in process memory. With one station app
|
||||
installed per PC the database is the only shared place, and any station app must be
|
||||
able to ask "what is going on here?" and get the same answer.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.api.middleware.api_key import get_current_user
|
||||
@@ -20,15 +20,32 @@ 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",
|
||||
}
|
||||
|
||||
|
||||
async def _require_run_operator(user: User = Depends(get_current_user)) -> User:
|
||||
"""Open/advance/pause/resume/close a run: MeasurementTec, Supervisor, or admin.
|
||||
|
||||
A Maker with no floor role could otherwise start and drive a production the
|
||||
same way an operator does (gitea#24) - reading a run stays open to any
|
||||
authenticated user, only the lifecycle actions are gated.
|
||||
"""
|
||||
if user.is_admin or user.has_role("MeasurementTec") or user.has_role("Supervisor"):
|
||||
return user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="MeasurementTec or Supervisor role required",
|
||||
)
|
||||
|
||||
|
||||
async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunResponse:
|
||||
"""The stored run plus everything the client cannot work out on its own."""
|
||||
return ProductionRunResponse(
|
||||
@@ -37,13 +54,15 @@ 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),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ProductionRunResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def open_production_run(
|
||||
data: ProductionRunCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(_require_run_operator),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Start a production at a station."""
|
||||
@@ -66,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,
|
||||
@@ -85,7 +125,7 @@ async def get_production_run(
|
||||
async def complete_measurement_cycle(
|
||||
run_id: int,
|
||||
payload: CycleCompletePayload | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(_require_run_operator),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Record a finished measurement task; the last one restarts the interval."""
|
||||
@@ -102,7 +142,7 @@ async def complete_measurement_cycle(
|
||||
async def remeasure_in_cycle(
|
||||
run_id: int,
|
||||
payload: RemeasurePayload | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(_require_run_operator),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Turn the piece over and measure again, without closing the cycle."""
|
||||
@@ -119,13 +159,14 @@ async def remeasure_in_cycle(
|
||||
async def pause_production_run(
|
||||
run_id: int,
|
||||
action: SupervisorAction,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(_require_run_operator),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Fermo linea - requires a supervisor."""
|
||||
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)
|
||||
@@ -135,13 +176,14 @@ async def pause_production_run(
|
||||
async def resume_production_run(
|
||||
run_id: int,
|
||||
action: SupervisorAction,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(_require_run_operator),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Restart a stopped line - requires a supervisor."""
|
||||
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)
|
||||
@@ -151,13 +193,14 @@ async def resume_production_run(
|
||||
async def close_production_run(
|
||||
run_id: int,
|
||||
action: SupervisorAction,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(_require_run_operator),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Fine produzione - requires a supervisor. Stops the timer for good."""
|
||||
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),
|
||||
@@ -189,6 +202,11 @@ async def parse_technical_sheet(
|
||||
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except ai_service.AIResponseError as e:
|
||||
# The AI model's own response was malformed (bad JSON, missing
|
||||
# fields): that is the upstream service's fault, not ours (400).
|
||||
logging.getLogger(__name__).error("AI response malformed: %s", e)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Risposta del servizio AI non valida")
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error("AI parsing error: %s", e)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Reports router — PDF report generation endpoints for Metrologist."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcNaiveInput
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import Response
|
||||
@@ -18,11 +18,12 @@ async def download_spc_report(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
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(
|
||||
@@ -52,11 +54,12 @@ async def download_measurement_report(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int | None = Query(None),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
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,5 +1,6 @@
|
||||
"""System settings router - read/update settings, upload company logo."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
@@ -8,11 +9,88 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
|
||||
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, detect_file_type
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
# Domains for the settings keys the backend actually reads. Values outside these
|
||||
# would only fail later (csv.writer TypeError on close_run, etc) — reject up front.
|
||||
_VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
|
||||
_VALID_CSV_DECIMAL_SEPARATORS = {",", "."}
|
||||
|
||||
|
||||
def _validate_string_list(key: str, value: str) -> None:
|
||||
"""#49: array JSON di stringhe non vuote, max 50 voci (vuoto = nessuna lista)."""
|
||||
try:
|
||||
items = json.loads(value)
|
||||
except ValueError:
|
||||
items = None
|
||||
if (
|
||||
not isinstance(items, list)
|
||||
or len(items) > 50
|
||||
or not all(isinstance(i, str) and i.strip() for i in items)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{key} deve essere vuoto o un array JSON di al massimo 50 stringhe non vuote",
|
||||
)
|
||||
|
||||
|
||||
def _validate_setting_value(key: str, value: str) -> None:
|
||||
"""Raise 422 for a value that would break the code reading this key.
|
||||
|
||||
Unrecognised keys pass through unvalidated: the settings store is generic.
|
||||
"""
|
||||
if key == "csv_delimiter":
|
||||
if value not in _VALID_CSV_DELIMITERS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="csv_delimiter deve essere un carattere singolo fra ',' ';' TAB '|'",
|
||||
)
|
||||
elif key == "csv_decimal_separator":
|
||||
if value not in _VALID_CSV_DECIMAL_SEPARATORS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="csv_decimal_separator deve essere ',' o '.'",
|
||||
)
|
||||
elif key == "auto_logout_minutes":
|
||||
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 480)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="auto_logout_minutes deve essere vuoto (disabilitato) o un intero fra 1 e 480",
|
||||
)
|
||||
elif key == "default_measurement_interval_minutes":
|
||||
# #47: vuoto = nessun default
|
||||
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 1440)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="default_measurement_interval_minutes deve essere vuoto o un intero fra 1 e 1440",
|
||||
)
|
||||
elif key in ("general_tolerance_plus", "general_tolerance_minus"):
|
||||
# #48: decimale >= 0 con punto; vuoto = nessun default
|
||||
if value != "":
|
||||
try:
|
||||
number = Decimal(value)
|
||||
ok = number.is_finite() and number >= 0
|
||||
except InvalidOperation:
|
||||
ok = False
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{key} deve essere vuoto o un numero decimale maggiore o uguale a 0 (punto decimale)",
|
||||
)
|
||||
elif key in ("list_measurement_types", "list_units"):
|
||||
if value != "":
|
||||
_validate_string_list(key, value)
|
||||
elif key in ("company_name", "company_logo_path"):
|
||||
if not value.strip() or len(value) > 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{key} deve essere una stringa non vuota di massimo 200 caratteri",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_settings(
|
||||
@@ -49,6 +127,8 @@ async def update_settings(
|
||||
updated_keys = []
|
||||
|
||||
for key, value in settings_data.items():
|
||||
_validate_setting_value(key, value)
|
||||
|
||||
# Check if setting exists
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.setting_key == key)
|
||||
@@ -88,35 +168,45 @@ async def upload_company_logo(
|
||||
"""Upload company logo.
|
||||
|
||||
Saves logo to uploads/logos/ and updates company_logo_path setting.
|
||||
Only admins can upload the logo.
|
||||
Only admins can upload the logo. The type is determined from the file's
|
||||
magic bytes, not the client-declared Content-Type (#36 point 2). SVG is
|
||||
refused outright: it can itself carry script content, and there is no
|
||||
safe way to serve it inline for a logo shown on every page.
|
||||
"""
|
||||
# Validate file type (must be image)
|
||||
allowed_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
|
||||
if file.content_type not in allowed_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {file.content_type} not allowed. Must be an image.",
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
# Read in chunks, stopping as soon as the size limit is crossed instead
|
||||
# of buffering an oversized body into memory first.
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
buffer = bytearray()
|
||||
chunk_size = 1024 * 1024
|
||||
while True:
|
||||
chunk = await file.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
)
|
||||
content = bytes(buffer)
|
||||
file_size = len(content)
|
||||
|
||||
# Validate file size
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
if file_size > max_bytes:
|
||||
# Validate file type from the actual bytes (SVG never matches: refused)
|
||||
detected = detect_file_type(content)
|
||||
if detected is None or detected[0] not in ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
detail="File type not allowed. Must be a recognised image (SVG is not accepted).",
|
||||
)
|
||||
_detected_mime, detected_ext = detected
|
||||
|
||||
# Create logos directory
|
||||
logos_dir = settings.upload_path / "logos"
|
||||
logos_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Use a fixed filename for the company logo
|
||||
file_extension = Path(file.filename).suffix
|
||||
logo_filename = f"company_logo{file_extension}"
|
||||
# Fixed filename for the company logo, extension forced to what was
|
||||
# actually detected (not what the client claimed or the filename said).
|
||||
logo_filename = f"company_logo{detected_ext}"
|
||||
logo_path = logos_dir / logo_filename
|
||||
|
||||
# Write file
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
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
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from src.backend.utils.tempo import iso_utc, utcnow
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -22,15 +26,29 @@ from src.backend.models.orm import (
|
||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
||||
)
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.services.auth_service import hash_password
|
||||
from src.backend.services.auth_service import hash_password, issue_api_key, revoke_all_keys
|
||||
from src.backend.services.measurement_service import calculate_pass_fail
|
||||
|
||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||
|
||||
_templates_dir = Path(__file__).resolve().parent.parent.parent / "templates"
|
||||
_backend_dir = Path(__file__).resolve().parent.parent.parent
|
||||
_templates_dir = _backend_dir / "templates"
|
||||
_alembic_ini = _backend_dir / "migrations" / "alembic.ini"
|
||||
templates = Jinja2Templates(directory=str(_templates_dir))
|
||||
|
||||
|
||||
def _stamp_head() -> None:
|
||||
"""Mark the schema as fully migrated - the equivalent of `alembic stamp
|
||||
head` - after `Base.metadata.create_all()`.
|
||||
|
||||
A database that got its tables this way instead of through
|
||||
`upgrade head` has no row in `alembic_version`; without this, its next
|
||||
deploy would try to replay migrations 001-013 and fail on a column that
|
||||
is already there (see migration 000_base_schema.py).
|
||||
"""
|
||||
command.stamp(Config(str(_alembic_ini)), "head")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -87,13 +105,57 @@ def _check_setup_enabled() -> None:
|
||||
def _check_password(password: str) -> None:
|
||||
"""Verify the provided password matches the setup password."""
|
||||
_check_setup_enabled()
|
||||
if password != settings.setup_password:
|
||||
if not hmac.compare_digest(password, settings.setup_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid setup password",
|
||||
)
|
||||
|
||||
|
||||
def _password_matches(password: str | None) -> bool:
|
||||
"""Constant-time password check that tolerates a missing password."""
|
||||
if not password or not settings.setup_password:
|
||||
return False
|
||||
return hmac.compare_digest(password, settings.setup_password)
|
||||
|
||||
|
||||
async def _admin_exists() -> bool:
|
||||
"""Whether at least one admin user has been created.
|
||||
|
||||
False on a database with no `users` table yet, so init-db and the first
|
||||
seed are never blocked by a query against a table that does not exist.
|
||||
"""
|
||||
async with engine.connect() as conn:
|
||||
has_users = await conn.run_sync(
|
||||
lambda sync_conn: sa_inspect(sync_conn).has_table("users")
|
||||
)
|
||||
if not has_users:
|
||||
return False
|
||||
async with async_session_factory() as session:
|
||||
result = await session.execute(select(User).where(User.is_admin == True)) # noqa: E712
|
||||
return result.scalars().first() is not None
|
||||
|
||||
|
||||
async def _require_not_initialised() -> None:
|
||||
"""Refuse privileged/destructive setup endpoints once an admin exists.
|
||||
|
||||
reset-db, seed, manage-user, change-user-password and init-db all reach
|
||||
into a live database once setup is done: reset-db wipes it, seed adds
|
||||
demo users and data, manage-user/change-user-password can take over an
|
||||
account, init-db races Alembic. All of them are meant for the one-time
|
||||
bring-up before there is an admin to log in as.
|
||||
`settings.setup_allow_after_init` is the explicit override for scripted
|
||||
re-provisioning of a demo/test deploy.
|
||||
"""
|
||||
if settings.setup_allow_after_init:
|
||||
return
|
||||
if await _admin_exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="setup already completed",
|
||||
)
|
||||
|
||||
|
||||
def _generate_measurements_for_subtask(
|
||||
subtask: RecipeSubtask,
|
||||
version_id: int,
|
||||
@@ -220,14 +282,24 @@ async def setup_page(request: Request):
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def setup_status():
|
||||
"""Return database table status (no password required, but setup must be enabled)."""
|
||||
async def setup_status(password: str | None = None):
|
||||
"""Database status. Without a valid password, only whether setup is done.
|
||||
|
||||
The table listing is only returned with the setup password: it is
|
||||
internal detail, not something to hand to whoever happens to load the
|
||||
page.
|
||||
"""
|
||||
_check_setup_enabled()
|
||||
initialised = await _admin_exists()
|
||||
if not _password_matches(password):
|
||||
return {"initialised": initialised}
|
||||
|
||||
async with engine.connect() as conn:
|
||||
table_names: list[str] = await conn.run_sync(
|
||||
lambda sync_conn: sa_inspect(sync_conn).get_table_names()
|
||||
)
|
||||
return {
|
||||
"initialised": initialised,
|
||||
"tables_exist": len(table_names) > 0,
|
||||
"table_count": len(table_names),
|
||||
"tables": table_names,
|
||||
@@ -238,8 +310,12 @@ async def setup_status():
|
||||
async def init_db(body: PasswordBody):
|
||||
"""Create all database tables."""
|
||||
_check_password(body.password)
|
||||
await _require_not_initialised()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# #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"}
|
||||
|
||||
|
||||
@@ -248,8 +324,6 @@ async def create_admin(body: CreateAdminBody):
|
||||
"""Create an admin user with all roles."""
|
||||
_check_password(body.password)
|
||||
|
||||
api_key = secrets.token_hex(32)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
user = User(
|
||||
@@ -259,9 +333,10 @@ async def create_admin(body: CreateAdminBody):
|
||||
display_name=body.admin_display_name,
|
||||
roles=["Maker", "MeasurementTec", "Metrologist"],
|
||||
is_admin=True,
|
||||
api_key=api_key,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
api_key = await issue_api_key(session, user)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
@@ -274,6 +349,7 @@ async def create_admin(body: CreateAdminBody):
|
||||
async def seed_demo_data(body: PasswordBody):
|
||||
"""Seed database with demo users and a sample recipe."""
|
||||
_check_password(body.password)
|
||||
await _require_not_initialised()
|
||||
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
@@ -330,7 +406,6 @@ async def seed_demo_data(body: PasswordBody):
|
||||
email=u["email"],
|
||||
roles=u["roles"],
|
||||
is_admin=u["is_admin"],
|
||||
api_key=secrets.token_hex(32),
|
||||
)
|
||||
session.add(user)
|
||||
created_users.append(user)
|
||||
@@ -484,7 +559,7 @@ async def seed_demo_data(body: PasswordBody):
|
||||
tec_user = created_users[2] # tec1
|
||||
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
|
||||
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
|
||||
base_date = datetime.now()
|
||||
base_date = utcnow()
|
||||
|
||||
all_subtasks = subtasks_1 + subtasks_2
|
||||
measurements_created = 0
|
||||
@@ -532,8 +607,8 @@ async def list_users(body: PasswordBody):
|
||||
"roles": u.roles or [],
|
||||
"is_admin": u.is_admin,
|
||||
"active": u.active,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
"last_login": u.last_login.isoformat() if u.last_login else None,
|
||||
"created_at": iso_utc(u.created_at) or None,
|
||||
"last_login": iso_utc(u.last_login) or None,
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
@@ -544,6 +619,7 @@ async def list_users(body: PasswordBody):
|
||||
async def manage_user(body: ManageUserBody):
|
||||
"""Create or update a user. user_id=null creates new, user_id=int updates."""
|
||||
_check_password(body.password)
|
||||
await _require_not_initialised()
|
||||
|
||||
invalid = set(body.roles) - VALID_ROLES
|
||||
if invalid:
|
||||
@@ -577,7 +653,6 @@ async def manage_user(body: ManageUserBody):
|
||||
email=body.email,
|
||||
roles=body.roles,
|
||||
is_admin=body.is_admin,
|
||||
api_key=secrets.token_hex(32),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
@@ -618,6 +693,7 @@ async def manage_user(body: ManageUserBody):
|
||||
async def change_user_password(body: ChangeUserPasswordBody):
|
||||
"""Change a user's password and invalidate their API key."""
|
||||
_check_password(body.password)
|
||||
await _require_not_initialised()
|
||||
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
@@ -631,7 +707,7 @@ async def change_user_password(body: ChangeUserPasswordBody):
|
||||
detail=f"User ID {body.user_id} not found",
|
||||
)
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
user.api_key = None # Invalidate sessions
|
||||
await revoke_all_keys(session, user.id) # Invalidate sessions
|
||||
|
||||
return {"status": "ok", "message": f"Password changed for '{user.username}'"}
|
||||
|
||||
@@ -654,7 +730,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
|
||||
)
|
||||
user.active = not user.active
|
||||
if not user.active:
|
||||
user.api_key = None # Invalidate sessions on deactivation
|
||||
await revoke_all_keys(session, user.id) # on deactivation
|
||||
new_status = "active" if user.active else "inactive"
|
||||
|
||||
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
|
||||
@@ -664,6 +740,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
|
||||
async def reset_db(body: ResetDbBody):
|
||||
"""Drop all tables and recreate them. Requires confirm=true."""
|
||||
_check_password(body.password)
|
||||
await _require_not_initialised()
|
||||
|
||||
if not body.confirm:
|
||||
raise HTTPException(
|
||||
@@ -674,5 +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)
|
||||
# #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,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"""Statistics router - SPC endpoints for Metrologist dashboard."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcNaiveInput
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, select
|
||||
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,
|
||||
@@ -37,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.
|
||||
|
||||
@@ -66,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)
|
||||
@@ -100,11 +107,12 @@ async def get_summary(
|
||||
recipe_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
subtask_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SummaryData:
|
||||
@@ -112,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)
|
||||
@@ -122,11 +131,12 @@ async def get_capability(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CapabilityData:
|
||||
@@ -134,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)
|
||||
@@ -145,11 +156,12 @@ async def get_control_chart(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ControlChartData:
|
||||
@@ -157,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]
|
||||
@@ -172,11 +185,12 @@ async def get_histogram(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
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),
|
||||
@@ -185,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)
|
||||
@@ -234,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,8 +17,13 @@ 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.services import recipe_service
|
||||
from src.backend.models.orm.vision import VisionReferenceImage
|
||||
from src.backend.services import recipe_service, vision_service
|
||||
|
||||
router = APIRouter(tags=["tasks"])
|
||||
|
||||
@@ -99,6 +104,64 @@ async def _ensure_version_is_current(db: AsyncSession, version_id: int) -> Recip
|
||||
return version
|
||||
|
||||
|
||||
async def _task_for_write(db: AsyncSession, task: RecipeTask, user: User) -> RecipeTask:
|
||||
"""Return the task to write to: the same one, or its copy in a new version.
|
||||
|
||||
A version that already has measurements cannot be edited on the spot - its
|
||||
recorded pass/fail would silently start meaning something else. Copy-on-write
|
||||
(the same one create_task/update_recipe use) gives the edit a fresh version
|
||||
and leaves the measured one exactly as it was when the readings were taken.
|
||||
The task is matched in the copy by its position (order_index), which
|
||||
_copy_tasks_to_version preserves exactly.
|
||||
"""
|
||||
if not await recipe_service.version_has_measurements(db, task.version_id):
|
||||
return task
|
||||
|
||||
recipe_id = task.recipe_id
|
||||
if recipe_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot determine the recipe for this task",
|
||||
)
|
||||
from src.backend.models.api.recipe import RecipeUpdate
|
||||
new_version = await recipe_service.create_new_version(
|
||||
db, recipe_id, RecipeUpdate(change_notes=f"Edited task: {task.title}"), user,
|
||||
)
|
||||
for candidate in new_version.tasks:
|
||||
if candidate.order_index == task.order_index:
|
||||
# TaskResponse.recipe_id reads task.version.recipe_id; new_version's
|
||||
# own tasks are loaded via selectinload, which does not also
|
||||
# populate the child->parent backref, so left alone this would
|
||||
# lazy-load .version outside of an awaited context and crash.
|
||||
candidate.version = new_version
|
||||
return candidate
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Could not locate the task in the new version",
|
||||
)
|
||||
|
||||
|
||||
async def _subtask_for_write(
|
||||
db: AsyncSession, task: RecipeTask, subtask: RecipeSubtask, user: User,
|
||||
) -> tuple[RecipeTask, RecipeSubtask]:
|
||||
"""Return (task, subtask) to write to: the same ones, or their copies.
|
||||
|
||||
Same copy-on-write as _task_for_write, one level down: once the task's copy
|
||||
is found, the quote is matched inside it by marker_number (unique per task).
|
||||
"""
|
||||
new_task = await _task_for_write(db, task, user)
|
||||
if new_task is task:
|
||||
return task, subtask
|
||||
|
||||
for candidate in new_task.subtasks:
|
||||
if candidate.marker_number == subtask.marker_number:
|
||||
return new_task, candidate
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Could not locate the quote in the new version",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -142,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()
|
||||
@@ -180,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()
|
||||
@@ -264,10 +338,16 @@ async def reorder_tasks(
|
||||
async def update_task(
|
||||
task_id: int,
|
||||
data: TaskUpdate,
|
||||
_user: User = Depends(require_maker),
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a task. The task must belong to the current version. Requires Maker role."""
|
||||
"""Update a task. The task must belong to the current version. Requires Maker role.
|
||||
|
||||
If that version already has measurements the edit goes through copy-on-write:
|
||||
a new version is created and its copy of the task is the one changed. The
|
||||
version with measurements is left untouched, so its recorded pass/fail keeps
|
||||
meaning what it meant when it was taken.
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
await _ensure_version_is_current(db, task.version_id)
|
||||
|
||||
@@ -275,6 +355,28 @@ 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():
|
||||
setattr(task, field, value)
|
||||
|
||||
@@ -286,18 +388,37 @@ async def update_task(
|
||||
@router.delete("/api/tasks/{task_id}", status_code=204)
|
||||
async def delete_task(
|
||||
task_id: int,
|
||||
_user: User = Depends(require_maker),
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a task and its subtasks. The task must belong to the current version.
|
||||
|
||||
Requires Maker role.
|
||||
Requires Maker role. Copy-on-write applies as in update_task: a version with
|
||||
measurements is never touched, the deletion lands on its copy instead.
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
await _ensure_version_is_current(db, task.version_id)
|
||||
|
||||
task = await _task_for_write(db, task, user)
|
||||
|
||||
# The rows go with the task (migration 014 cascades them), the files do not:
|
||||
# collect them now, remove the files only once the commit is durable - same
|
||||
# order as vision.py::delete_reference_image. Rows are deleted explicitly so
|
||||
# file_still_referenced sees the post-delete state even where the database
|
||||
# does not enforce the cascade.
|
||||
references = (await db.execute(
|
||||
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
|
||||
)).scalars().all()
|
||||
for reference in references:
|
||||
await db.delete(reference)
|
||||
|
||||
await db.delete(task)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
# A copy-on-write sibling may still point at the same file (#38.6).
|
||||
for reference in references:
|
||||
if not await vision_service.file_still_referenced(db, reference.path):
|
||||
vision_service.remove_reference_file(reference)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -312,16 +433,19 @@ async def delete_task(
|
||||
async def create_subtask(
|
||||
task_id: int,
|
||||
data: SubtaskCreate,
|
||||
_user: User = Depends(require_maker),
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a subtask to a task. The task must belong to the current version.
|
||||
|
||||
Requires Maker role.
|
||||
Requires Maker role. Copy-on-write applies as in create_task: a version with
|
||||
measurements gets a new version, and the subtask is added there instead.
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
await _ensure_version_is_current(db, task.version_id)
|
||||
|
||||
task = await _task_for_write(db, task, user)
|
||||
|
||||
# Check marker_number uniqueness within the task
|
||||
existing_markers = {s.marker_number for s in task.subtasks}
|
||||
if data.marker_number in existing_markers:
|
||||
@@ -331,7 +455,7 @@ async def create_subtask(
|
||||
)
|
||||
|
||||
subtask = RecipeSubtask(
|
||||
task_id=task_id,
|
||||
task_id=task.id,
|
||||
marker_number=data.marker_number,
|
||||
description=data.description,
|
||||
measurement_type=data.measurement_type,
|
||||
@@ -354,12 +478,13 @@ async def create_subtask(
|
||||
async def update_subtask(
|
||||
subtask_id: int,
|
||||
data: SubtaskUpdate,
|
||||
_user: User = Depends(require_maker),
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a subtask. Its parent task must belong to the current version.
|
||||
|
||||
Requires Maker role.
|
||||
Requires Maker role. Copy-on-write applies as in update_task, one level
|
||||
down: a version with measurements is never touched, the quote's copy is.
|
||||
"""
|
||||
subtask = await _get_subtask_or_404(db, subtask_id)
|
||||
|
||||
@@ -371,6 +496,24 @@ async def update_subtask(
|
||||
if not update_data:
|
||||
return SubtaskResponse.model_validate(subtask)
|
||||
|
||||
# SubtaskUpdate is partial, so the order can only be checked against the
|
||||
# values the write would actually leave in place: existing + incoming.
|
||||
def _resulting(field: str) -> float | None:
|
||||
value = update_data.get(field, getattr(subtask, field))
|
||||
return None if value is None else float(value)
|
||||
|
||||
try:
|
||||
validate_tolerance_order(
|
||||
_resulting("ltl"), _resulting("lwl"), _resulting("nominal"),
|
||||
_resulting("uwl"), _resulting("utl"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc),
|
||||
)
|
||||
|
||||
task, subtask = await _subtask_for_write(db, task, subtask, user)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(subtask, field, value)
|
||||
|
||||
@@ -382,12 +525,13 @@ async def update_subtask(
|
||||
@router.delete("/api/subtasks/{subtask_id}", status_code=204)
|
||||
async def delete_subtask(
|
||||
subtask_id: int,
|
||||
_user: User = Depends(require_maker),
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a subtask. Its parent task must belong to the current version.
|
||||
|
||||
Requires Maker role.
|
||||
Requires Maker role. Copy-on-write applies as in update_task, one level
|
||||
down: a version with measurements is never touched, the quote's copy is.
|
||||
"""
|
||||
subtask = await _get_subtask_or_404(db, subtask_id)
|
||||
|
||||
@@ -395,5 +539,7 @@ async def delete_subtask(
|
||||
task = await _get_task_or_404(db, subtask.task_id)
|
||||
await _ensure_version_is_current(db, task.version_id)
|
||||
|
||||
task, subtask = await _subtask_for_write(db, task, subtask, user)
|
||||
|
||||
await db.delete(subtask)
|
||||
await db.flush()
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
"""Users router - CRUD operations (admin only)."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_admin_user
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key
|
||||
from src.backend.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"])
|
||||
|
||||
@@ -78,6 +84,21 @@ async def update_user(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# roles and is_admin are NOT NULL columns: an explicit null passed the
|
||||
# Optional[...] schema check but blew up at flush time as an unhandled
|
||||
# 500 (#36 point 4).
|
||||
if "roles" in update_data and update_data["roles"] is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="roles cannot be null",
|
||||
)
|
||||
if "is_admin" in update_data and update_data["is_admin"] is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="is_admin cannot be null",
|
||||
)
|
||||
|
||||
# Validate roles if provided
|
||||
if "roles" in update_data:
|
||||
for role in update_data["roles"]:
|
||||
@@ -86,6 +107,27 @@ async def update_user(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
|
||||
)
|
||||
|
||||
# Losing the last admin locks everyone out of user management; losing
|
||||
# your own admin flag while acting as admin is the same mistake aimed
|
||||
# at yourself. Neither is reversible without direct DB access.
|
||||
if update_data.get("is_admin") is False and user.is_admin:
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot remove your own admin privilege",
|
||||
)
|
||||
other_admins = await db.execute(
|
||||
select(func.count()).select_from(User).where(
|
||||
User.is_admin == True, User.id != user.id # noqa: E712
|
||||
)
|
||||
)
|
||||
if other_admins.scalar_one() == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot remove admin privilege from the last admin",
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
await db.flush()
|
||||
@@ -100,16 +142,45 @@ async def change_user_password(
|
||||
admin: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change user password (admin only)."""
|
||||
"""Change user password (admin only). Invalidates the user's API key,
|
||||
|
||||
the same as setup.py's change-user-password: a password change should
|
||||
force everyone logged in as that user to log back in.
|
||||
"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user.password_hash = hash_password(data.password)
|
||||
await revoke_all_keys(db, user.id)
|
||||
await db.flush()
|
||||
return {"message": f"Password changed for user {user.username}"}
|
||||
|
||||
|
||||
@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,
|
||||
@@ -127,7 +198,7 @@ async def deactivate_user(
|
||||
detail="Cannot deactivate yourself",
|
||||
)
|
||||
user.active = False
|
||||
user.api_key = None
|
||||
await revoke_all_keys(db, user.id)
|
||||
await db.flush()
|
||||
|
||||
|
||||
@@ -142,5 +213,5 @@ async def regenerate_user_key(
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
new_key = await regenerate_api_key(db, user_id)
|
||||
new_key = await regenerate_api_key(db, user)
|
||||
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
|
||||
|
||||
@@ -4,18 +4,32 @@ It does not know, and must not know, whether the server or the station computed
|
||||
the numbers: that is what lets a station's configuration change without touching
|
||||
the frontend or the recipes.
|
||||
"""
|
||||
import base64
|
||||
import binascii
|
||||
import io
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
|
||||
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, 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
|
||||
from src.backend.models.api.vision import (
|
||||
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
|
||||
from src.backend.services import station_service, vision_service
|
||||
|
||||
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
||||
|
||||
@@ -28,6 +42,29 @@ _IMAGE_EXTENSIONS = {
|
||||
"image/webp": "webp",
|
||||
}
|
||||
|
||||
# Sniffed from the first bytes, independent of the declared Content-Type: a
|
||||
# text file uploaded as "image/png" passes the header check above and must
|
||||
# still be caught before it reaches the worker as a bare 500 (#15). BMP and
|
||||
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
|
||||
# because PIL - and so the worker - can decode both; this check only refuses
|
||||
# what is not a real image at all, it does not narrow the allow-list above.
|
||||
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
|
||||
(b"\x89PNG\r\n\x1a\n",), # PNG
|
||||
(b"\xff\xd8\xff",), # JPEG
|
||||
(b"BM",), # BMP
|
||||
(b"II*\x00", b"MM\x00*"), # TIFF, little- and big-endian
|
||||
(b"GIF87a", b"GIF89a"), # GIF
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_an_image(content: bytes) -> bool:
|
||||
"""True if `content` starts with a known image format's magic bytes."""
|
||||
for signatures in _IMAGE_SIGNATURES:
|
||||
if any(content.startswith(sig) for sig in signatures):
|
||||
return True
|
||||
# WebP: "RIFF" + 4-byte size + "WEBP", the size in between is not fixed.
|
||||
return len(content) >= 12 and content[:4] == b"RIFF" and content[8:12] == b"WEBP"
|
||||
|
||||
|
||||
async def _task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
|
||||
"""Shared by the endpoints of this router."""
|
||||
@@ -60,22 +97,45 @@ async def _read_image(image: UploadFile) -> bytes:
|
||||
f"{settings.max_upload_size_mb}MB"
|
||||
),
|
||||
)
|
||||
if not _looks_like_an_image(content):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="the file's content does not match a supported image format",
|
||||
)
|
||||
_reject_animated(content)
|
||||
return content
|
||||
|
||||
|
||||
def _reject_animated(content: bytes) -> None:
|
||||
"""Animated GIF/WebP/APNG have no single frame to measure (decision of
|
||||
06/10/2026, #43): refuse them instead of silently reading frame 0."""
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as img:
|
||||
animated = getattr(img, "is_animated", False) or getattr(img, "n_frames", 1) > 1
|
||||
except Exception:
|
||||
# Unreadable here is not this check's business: the magic bytes passed
|
||||
# and the worker decides what it can decode.
|
||||
return
|
||||
if animated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="animated images are not supported",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/execute", response_model=VisionExecuteResponse)
|
||||
async def execute(
|
||||
task_id: int = Form(...),
|
||||
image: UploadFile = File(...),
|
||||
lot_number: str | None = Form(None),
|
||||
serial_number: str | None = Form(None),
|
||||
production_run_id: int | None = Form(None),
|
||||
lot_number: str | None = Form(None, max_length=100),
|
||||
serial_number: str | None = Form(None, max_length=100),
|
||||
production_run_id: int | None = Form(None, gt=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_measurement_tec),
|
||||
) -> VisionExecuteResponse:
|
||||
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),
|
||||
@@ -84,6 +144,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()
|
||||
|
||||
@@ -96,11 +157,128 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
async def _store_validate_assign(
|
||||
task: RecipeTask, graph: dict, placed: dict[str, bytes],
|
||||
) -> tuple[str, dict]:
|
||||
"""Scrive la cartella ricetta, la fa validare dal worker, la assegna al task.
|
||||
|
||||
Condiviso da upload_recipe e dxf-compare con save (#83): stesso
|
||||
comportamento, un solo posto. Non committa. Ritorna (recipe_dir, check).
|
||||
"""
|
||||
# The folder is content-addressed and immutable; writing it before the
|
||||
# validation is harmless (an orphan folder is never referenced) and the
|
||||
# worker needs it on disk to validate meta files.
|
||||
try:
|
||||
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
||||
except ValueError as exc:
|
||||
raise _422([str(exc)])
|
||||
check = await vision_service.validate_recipe(graph, recipe_dir)
|
||||
if not check["ok"]:
|
||||
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
||||
|
||||
task.vision_json = graph
|
||||
task.vision_recipe_dir = recipe_dir
|
||||
return recipe_dir, check
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
||||
async def upload_recipe(
|
||||
task_id: int,
|
||||
recipe: UploadFile = File(...),
|
||||
files: list[UploadFile] = File(default=[]),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> VisionRecipeUploadResponse:
|
||||
"""Load a VisionSuite v2 recipe plus its meta files onto a task (#46)."""
|
||||
task = await _task_or_404(db, task_id)
|
||||
if task.task_type not in vision_service.VISION_TASK_TYPES:
|
||||
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
|
||||
|
||||
raw = await recipe.read()
|
||||
if not validate_file_size(len(raw)):
|
||||
raise _422([f"recipe exceeds {settings.max_upload_size_mb}MB"])
|
||||
try:
|
||||
graph = json.loads(raw)
|
||||
except ValueError:
|
||||
raise _422(["the recipe is not valid JSON"])
|
||||
if not isinstance(graph, dict):
|
||||
raise _422(["the recipe must be a JSON object"])
|
||||
|
||||
uploads: list[tuple[str, bytes]] = []
|
||||
for f in files:
|
||||
content = await f.read()
|
||||
if not validate_file_size(len(content)):
|
||||
raise _422([f"file {f.filename!r} exceeds {settings.max_upload_size_mb}MB"])
|
||||
uploads.append((f.filename or "", content))
|
||||
placed, errors = vision_service.match_recipe_files(graph, uploads)
|
||||
if errors:
|
||||
raise _422(errors)
|
||||
|
||||
recipe_dir, check = await _store_validate_assign(task, graph, placed)
|
||||
await db.commit()
|
||||
return VisionRecipeUploadResponse(
|
||||
task_id=task.id,
|
||||
recipe_dir=recipe_dir,
|
||||
files=vision_service.list_recipe_files(recipe_dir),
|
||||
outputs=check["outputs"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/recipe", response_model=VisionRecipeResponse)
|
||||
async def get_recipe(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> VisionRecipeResponse:
|
||||
task = await _task_or_404(db, task_id)
|
||||
outputs: list[dict] = []
|
||||
if task.vision_json:
|
||||
check = await vision_service.validate_recipe(
|
||||
task.vision_json, task.vision_recipe_dir,
|
||||
)
|
||||
outputs = check.get("outputs", [])
|
||||
return VisionRecipeResponse(
|
||||
task_id=task.id,
|
||||
recipe_dir=task.vision_recipe_dir,
|
||||
recipe=task.vision_json,
|
||||
files=vision_service.list_recipe_files(task.vision_recipe_dir),
|
||||
outputs=outputs,
|
||||
)
|
||||
|
||||
|
||||
# Composing a recipe and keeping a reference image are Maker operations, not
|
||||
# measuring: both endpoints below gate on `require_maker`, not the
|
||||
# measuring: every endpoint below gates on `require_maker`, not the
|
||||
# `require_measurement_tec` that `execute` above uses.
|
||||
|
||||
|
||||
@@ -118,10 +296,41 @@ async def add_reference_image(
|
||||
db, task.id, content, note=note,
|
||||
extension=_IMAGE_EXTENSIONS[image.content_type],
|
||||
)
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception:
|
||||
# The row never became durable - the file `save_reference_image`
|
||||
# wrote after its flush would otherwise outlive it as an orphan.
|
||||
vision_service.remove_reference_file(reference)
|
||||
raise
|
||||
return {"id": reference.id, "path": reference.path, "note": reference.note}
|
||||
|
||||
|
||||
@router.get("/reference-images", response_model=list[VisionReferenceImageResponse])
|
||||
async def get_reference_images(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> list[VisionReferenceImageResponse]:
|
||||
await _task_or_404(db, task_id)
|
||||
return await vision_service.list_reference_images(db, task_id)
|
||||
|
||||
|
||||
@router.delete("/reference-images/{reference_id}", status_code=204)
|
||||
async def delete_reference_image(
|
||||
reference_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> None:
|
||||
reference = await vision_service.delete_reference_image(db, reference_id)
|
||||
await db.commit()
|
||||
# Only now: if the commit above had failed, the row would still exist and
|
||||
# the file must still be there for it. And only if no other row (a
|
||||
# copy-on-write sibling, #38.6) still references the same path.
|
||||
if not await vision_service.file_still_referenced(db, reference.path):
|
||||
vision_service.remove_reference_file(reference)
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
async def preview(
|
||||
task_id: int = Form(...),
|
||||
@@ -131,3 +340,77 @@ async def preview(
|
||||
) -> dict:
|
||||
task = await _task_or_404(db, task_id)
|
||||
return await vision_service.preview(db, task, await _read_image(image))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
||||
async def create_dxf_compare_recipe(
|
||||
task_id: int,
|
||||
image: UploadFile = File(...),
|
||||
dxf: UploadFile = File(...),
|
||||
station_id: int | None = Form(None, gt=0),
|
||||
px_per_mm: float | None = Form(None, gt=0),
|
||||
band_mm: float = Form(0.1, gt=0),
|
||||
min_points_pct: float = Form(95, ge=0, le=100),
|
||||
polarity: Literal["dark", "light"] = Form("dark"),
|
||||
save: bool = Form(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> dict:
|
||||
"""Ricetta DXF compare da un'immagine e un DXF, senza Studio (#83).
|
||||
|
||||
La scala e' `px_per_mm`, o quella della stazione (letta dall'acquisitore).
|
||||
Con save=false si vede il risultato e non si scrive nulla; con save=true la
|
||||
ricetta va sul task come con l'upload, e se il task non ha nessuna quota di
|
||||
visione nasce la quota «Punti in tolleranza».
|
||||
"""
|
||||
task = await _task_or_404(db, task_id)
|
||||
if task.task_type not in vision_service.VISION_TASK_TYPES:
|
||||
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
|
||||
|
||||
station = None
|
||||
if station_id is not None:
|
||||
station = await station_service.get_station(db, station_id) # 404 se non esiste
|
||||
if px_per_mm is None:
|
||||
calibration, reason = await vision_service.fetch_station_calibration(station)
|
||||
if calibration is None:
|
||||
# irraggiungibile e non calibrata sono due guasti diversi per chi lo legge
|
||||
raise _422([
|
||||
"acquisitore della stazione non raggiungibile"
|
||||
if reason == vision_service.CAL_UNREACHABLE else "stazione non calibrata"
|
||||
])
|
||||
px_per_mm = float(calibration["px_per_mm"])
|
||||
|
||||
image_bytes = await _read_image(image)
|
||||
dxf_bytes = await dxf.read()
|
||||
if not validate_file_size(len(dxf_bytes)):
|
||||
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
|
||||
|
||||
body = await vision_service.dxf_compare_recipe(
|
||||
image_bytes, dxf_bytes,
|
||||
px_per_mm=px_per_mm, band_mm=band_mm, min_points_pct=min_points_pct,
|
||||
polarity=polarity,
|
||||
worker_url=(station.vision_worker_url or None) if station is not None else None,
|
||||
)
|
||||
# i file stanno nella ricetta salvata, non nella risposta al client
|
||||
files = body.pop("files")
|
||||
response = {"result": body, "saved": False, "recipe_dir": None, "outputs": None}
|
||||
if not save:
|
||||
return response
|
||||
|
||||
uploads: list[tuple[str, bytes]] = []
|
||||
for name, encoded in files.items():
|
||||
try:
|
||||
uploads.append((name, base64.b64decode(encoded, validate=True)))
|
||||
except (binascii.Error, ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker's answer was malformed: file {name!r} is not base64",
|
||||
)
|
||||
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
|
||||
if errors:
|
||||
raise _422(errors)
|
||||
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
|
||||
await vision_service.ensure_points_quote(db, task, min_points_pct)
|
||||
await db.commit()
|
||||
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
|
||||
return response
|
||||
|
||||
+76
-4
@@ -1,23 +1,58 @@
|
||||
"""TieMeasureFlow Server Configuration."""
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
# Committed defaults. Fine for dev/test (SQLite in-memory, nothing exposed); a
|
||||
# real deployment that still has these is running on secrets anyone can read
|
||||
# in this file's git history.
|
||||
_DEFAULT_SECRET_KEY = "change-this-to-a-random-secret-key"
|
||||
_DEFAULT_DB_PASSWORD = "change_me_in_production"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# Deployment environment. "dev" and "test" skip the production-only guard
|
||||
# below; anything else (e.g. "production") is treated as a real
|
||||
# deployment. Local dev and the test suite must keep starting with no
|
||||
# .env at all, which is why this defaults to "dev" rather than requiring
|
||||
# the variable.
|
||||
app_env: str = "dev"
|
||||
|
||||
# Database
|
||||
db_host: str = "localhost"
|
||||
db_port: int = 3306
|
||||
db_name: str = "tiemeasureflow"
|
||||
db_user: str = "tmflow"
|
||||
db_password: str = "change_me_in_production"
|
||||
db_password: str = _DEFAULT_DB_PASSWORD
|
||||
# Full SQLAlchemy async URL (env DB_URL). When set it wins over the
|
||||
# DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD parts. Used by the Windows
|
||||
# package: sqlite+aiosqlite:///C:/ProgramData/.../tmf.db (forward slashes).
|
||||
db_url: str | None = None
|
||||
|
||||
# Presentation time zone (DB is UTC, #42)
|
||||
display_timezone: str = "Europe/Rome"
|
||||
|
||||
# Server
|
||||
server_host: str = "0.0.0.0"
|
||||
server_port: int = 8000
|
||||
server_secret_key: str = "change-this-to-a-random-secret-key"
|
||||
server_secret_key: str = _DEFAULT_SECRET_KEY
|
||||
server_cors_origins: str = "http://localhost:5000"
|
||||
# Worker processes (e.g. `uvicorn --workers N`). Each rate-limit bucket lives
|
||||
# in process memory, so this is how RateLimitMiddleware divides the
|
||||
# configured limits to keep the cluster-wide effective limit close to what
|
||||
# was configured instead of multiplying it by N.
|
||||
web_concurrency: int = 1
|
||||
|
||||
# API key session lifetime (hours); renewed on use (env API_KEY_TTL_HOURS).
|
||||
api_key_ttl_hours: int = 24
|
||||
# How many reverse proxies between the client and this API are trusted to
|
||||
# have appended their own hop to X-Forwarded-For (Nginx, in the default
|
||||
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
|
||||
# parsing entirely (only request.client.host is trusted).
|
||||
trusted_proxy_count: int = 1
|
||||
|
||||
# File Storage
|
||||
upload_dir: str = "uploads"
|
||||
@@ -33,6 +68,10 @@ class Settings(BaseSettings):
|
||||
|
||||
# Setup page (empty = disabled)
|
||||
setup_password: str | None = None
|
||||
# By default, once an admin user exists, setup's privileged endpoints
|
||||
# (reset-db, seed, manage-user, change-user-password, init-db) refuse to
|
||||
# run again. Set true for scripted re-provisioning of a demo/test deploy.
|
||||
setup_allow_after_init: bool = False
|
||||
|
||||
# AI / OpenRouter (for technical sheet parsing)
|
||||
openrouter_api_key: str | None = None
|
||||
@@ -43,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}"
|
||||
@@ -62,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).
|
||||
@@ -72,5 +118,31 @@ class Settings(BaseSettings):
|
||||
"extra": "ignore",
|
||||
}
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _refuse_default_secrets_outside_dev(self) -> "Settings":
|
||||
"""Refuse to boot with a committed default secret in a real deployment.
|
||||
|
||||
Only fires when APP_ENV names an actual deployment (not "dev"/"test"),
|
||||
so local development and the test suite keep starting with no .env at
|
||||
all.
|
||||
"""
|
||||
if self.app_env in ("dev", "test"):
|
||||
return self
|
||||
if self.server_secret_key == _DEFAULT_SECRET_KEY:
|
||||
raise RuntimeError(
|
||||
"SERVER_SECRET_KEY is still the committed default value; "
|
||||
"set it before running with APP_ENV=" + self.app_env
|
||||
)
|
||||
# A SQLite file has no DB password: the default-password guard only
|
||||
# makes sense for the MySQL connection built from the DB_* parts.
|
||||
if self.db_url and self.db_url.startswith("sqlite"):
|
||||
return self
|
||||
if self.db_password == _DEFAULT_DB_PASSWORD:
|
||||
raise RuntimeError(
|
||||
"DB_PASSWORD is still the committed default value; "
|
||||
"set it before running with APP_ENV=" + self.app_env
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
+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")
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""base schema: state of the database immediately before migration 001
|
||||
|
||||
Every database that has run this project since before Alembic was wired in
|
||||
was born from `Base.metadata.create_all()` (via `/api/setup/init-db`, or
|
||||
`init_db()` in dev) - never from a migration chain, because there wasn't one.
|
||||
`upgrade head` on a genuinely empty database therefore had no starting point:
|
||||
001 is the oldest migration, and it only knows how to ADD columns to tables
|
||||
that 001-013 all assume already exist.
|
||||
|
||||
This migration supplies that starting point, but only when there is nothing
|
||||
there yet: on a database that already has `recipes` (the table 001 was
|
||||
written against), it is a no-op, because that database was already born from
|
||||
`create_all()` and already has this exact schema.
|
||||
|
||||
Written explicitly with `create_table` rather than `Base.metadata.create_all`:
|
||||
`create_all` builds the CURRENT model definitions, which already carry every
|
||||
column 001-013 add, and 001 would then try to add a column that is already
|
||||
there (`Duplicate column`).
|
||||
|
||||
Revision ID: 000_base
|
||||
Revises:
|
||||
Create Date: 2026-09-21
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '000_base'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# Table creation order matters: each one only references tables created
|
||||
# before it (users -> recipes -> recipe_versions -> recipe_tasks ->
|
||||
# recipe_subtasks -> measurements -> access_logs / system_settings /
|
||||
# recipe_version_audit). Downgrade drops them in the opposite order.
|
||||
_TABLES_NEWEST_FIRST = (
|
||||
'recipe_version_audit', 'system_settings', 'access_logs', 'measurements',
|
||||
'recipe_subtasks', 'recipe_tasks', 'recipe_versions', 'recipes', 'users',
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if sa_inspect(bind).has_table('recipes'):
|
||||
# Already created (by create_all, or by a previous run of this same
|
||||
# migration) - nothing to do.
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('username', sa.String(100), nullable=False),
|
||||
sa.Column('password_hash', sa.String(255), nullable=False),
|
||||
sa.Column('email', sa.String(255), nullable=True),
|
||||
sa.Column('display_name', sa.String(255), nullable=False),
|
||||
sa.Column('roles', sa.JSON, nullable=False),
|
||||
sa.Column('is_admin', sa.Boolean, nullable=False, server_default='0'),
|
||||
sa.Column(
|
||||
'language_pref', sa.Enum('it', 'en', name='language_enum'),
|
||||
nullable=False, server_default='it',
|
||||
),
|
||||
sa.Column(
|
||||
'theme_pref', sa.Enum('light', 'dark', name='theme_enum'),
|
||||
nullable=False, server_default='light',
|
||||
),
|
||||
sa.Column('active', sa.Boolean, nullable=False, server_default='1'),
|
||||
sa.Column('api_key', sa.String(64), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('last_login', sa.DateTime, nullable=True),
|
||||
sa.UniqueConstraint('username', name='uq_users_username'),
|
||||
sa.UniqueConstraint('api_key', name='uq_users_api_key'),
|
||||
sa.Index('ix_users_username', 'username'),
|
||||
sa.Index('ix_users_api_key', 'api_key'),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'recipes',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('code', sa.String(100), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text, nullable=True),
|
||||
sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('active', sa.Boolean, nullable=False, server_default='1'),
|
||||
sa.UniqueConstraint('code', name='uq_recipes_code'),
|
||||
sa.Index('ix_recipes_code', 'code'),
|
||||
sa.Index('ix_recipes_active', 'active'),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'recipe_versions',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False),
|
||||
sa.Column('version_number', sa.Integer, nullable=False),
|
||||
sa.Column('is_current', sa.Boolean, nullable=False, server_default='0'),
|
||||
sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('change_notes', sa.Text, nullable=True),
|
||||
sa.UniqueConstraint('recipe_id', 'version_number', name='uq_recipe_version'),
|
||||
sa.Index('ix_recipe_version_current', 'recipe_id', 'is_current'),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'recipe_tasks',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'), nullable=False),
|
||||
sa.Column('order_index', sa.Integer, nullable=False, server_default='0'),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('directive', sa.Text, nullable=True),
|
||||
sa.Column('description', sa.Text, nullable=True),
|
||||
sa.Column('file_path', sa.String(500), nullable=True),
|
||||
sa.Column(
|
||||
'file_type', sa.Enum('image', 'pdf', name='file_type_enum'),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column('annotations_json', sa.JSON, nullable=True),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'recipe_subtasks',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
'task_id', sa.Integer,
|
||||
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False,
|
||||
),
|
||||
sa.Column('marker_number', sa.Integer, nullable=False),
|
||||
sa.Column('description', sa.String(500), nullable=False),
|
||||
sa.Column('measurement_type', sa.String(100), nullable=True),
|
||||
sa.Column('nominal', sa.DECIMAL(12, 6), nullable=True),
|
||||
sa.Column('utl', sa.DECIMAL(12, 6), nullable=True),
|
||||
sa.Column('uwl', sa.DECIMAL(12, 6), nullable=True),
|
||||
sa.Column('lwl', sa.DECIMAL(12, 6), nullable=True),
|
||||
sa.Column('ltl', sa.DECIMAL(12, 6), nullable=True),
|
||||
sa.Column('unit', sa.String(20), nullable=False, server_default='mm'),
|
||||
sa.UniqueConstraint('task_id', 'marker_number', name='uq_task_marker'),
|
||||
sa.Index('ix_subtask_task_id', 'task_id'),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'measurements',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
'subtask_id', sa.Integer, sa.ForeignKey('recipe_subtasks.id'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
'version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
'measured_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False,
|
||||
),
|
||||
sa.Column('value', sa.DECIMAL(12, 6), nullable=False),
|
||||
sa.Column(
|
||||
'pass_fail', sa.Enum('pass', 'warning', 'fail', name='pass_fail_enum'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('deviation', sa.DECIMAL(12, 6), nullable=True),
|
||||
sa.Column('lot_number', sa.String(100), nullable=True),
|
||||
sa.Column('serial_number', sa.String(100), nullable=True),
|
||||
sa.Column(
|
||||
'input_method',
|
||||
sa.Enum('usb_caliper', 'manual', name='input_method_enum'),
|
||||
nullable=False, server_default='manual',
|
||||
),
|
||||
sa.Column(
|
||||
'measured_at', sa.DateTime, nullable=False, server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column('synced_to_csv', sa.Boolean, nullable=True, server_default='0'),
|
||||
sa.Index('ix_measurements_subtask_id', 'subtask_id'),
|
||||
sa.Index('ix_measurements_version_id', 'version_id'),
|
||||
sa.Index('ix_measurements_measured_by', 'measured_by'),
|
||||
sa.Index('ix_measurements_lot_number', 'lot_number'),
|
||||
sa.Index('ix_measurements_serial_number', 'serial_number'),
|
||||
sa.Index('ix_measurements_measured_at', 'measured_at'),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'access_logs',
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('action', sa.String(100), nullable=False),
|
||||
sa.Column('details', sa.JSON, nullable=True),
|
||||
sa.Column('ip_address', sa.String(45), nullable=True),
|
||||
sa.Column('user_agent', sa.String(500), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.Index('ix_access_logs_action', 'action'),
|
||||
sa.Index('ix_access_log_user_time', 'user_id', 'created_at'),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'system_settings',
|
||||
sa.Column('setting_key', sa.String(100), primary_key=True),
|
||||
sa.Column('setting_value', sa.Text, nullable=False),
|
||||
sa.Column(
|
||||
'setting_type',
|
||||
sa.Enum('string', 'number', 'boolean', 'json', name='setting_type_enum'),
|
||||
nullable=False, server_default='string',
|
||||
),
|
||||
sa.Column('description', sa.String(500), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'recipe_version_audit',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False),
|
||||
sa.Column('old_version_id', sa.Integer, nullable=True),
|
||||
sa.Column('new_version_id', sa.Integer, nullable=False),
|
||||
sa.Column('changed_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column(
|
||||
'change_type',
|
||||
sa.Enum('CREATE', 'UPDATE', 'ACTIVATE', 'RETIRE', name='change_type_enum'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('change_reason', sa.Text, nullable=True),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.Index('ix_recipe_audit_recipe_time', 'recipe_id', 'created_at'),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa_inspect(bind)
|
||||
for table in _TABLES_NEWEST_FIRST:
|
||||
if inspector.has_table(table):
|
||||
op.drop_table(table)
|
||||
@@ -12,7 +12,7 @@ import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001_image_path'
|
||||
down_revision: Union[str, None] = None
|
||||
down_revision: Union[str, None] = '000_base'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""vision results and reference images cascade with their task
|
||||
|
||||
Both `vision_results.task_id` and `vision_reference_images.task_id` were
|
||||
created (012, 013) with a plain, unnamed foreign key - no ON DELETE clause,
|
||||
so deleting a task with any camera history or reference image attached fails
|
||||
on a strict FK check (MySQL) instead of taking the vision rows with it, the
|
||||
way `production_events.run_id` already does for a closed production run.
|
||||
|
||||
The original constraint has no name to drop by (SQLAlchemy leaves inline
|
||||
`ForeignKey(...)` unnamed unless told otherwise, and MySQL then assigns its
|
||||
own `<table>_ibfk_N`, which is not portable to reflect on). `sa.inspect`
|
||||
looks it up at migration time instead of guessing it - the same way on
|
||||
MySQL and on SQLite (batch mode), where an anonymous FK reflects with
|
||||
`name=None` and there is nothing to drop before adding the new one.
|
||||
|
||||
Reference images on disk are not touched here - deleting a task's row does
|
||||
not, on its own, know to also delete the file next to it; that gap is
|
||||
declared open in issue #38 point 6's write-up (`vision_service` has no
|
||||
single point today that intercepts a task deletion).
|
||||
|
||||
Revision ID: 014_vision_fk_ondelete
|
||||
Revises: 013_reference_images
|
||||
Create Date: 2026-09-21
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '014_vision_fk_ondelete'
|
||||
down_revision: Union[str, None] = '013_reference_images'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_NEW_FK_NAMES = {
|
||||
'vision_results': 'fk_vision_results_task_id',
|
||||
'vision_reference_images': 'fk_vision_reference_images_task_id',
|
||||
}
|
||||
|
||||
|
||||
def _existing_task_id_fk_name(bind, table_name: str) -> Union[str, None]:
|
||||
"""The name reflection gives the pre-existing `task_id` FK, if any.
|
||||
|
||||
None on SQLite for a constraint that was never named - batch mode then
|
||||
has nothing to drop, and just adds the new named one alongside it.
|
||||
"""
|
||||
inspector = sa.inspect(bind)
|
||||
for fk in inspector.get_foreign_keys(table_name):
|
||||
if fk.get("constrained_columns") == ["task_id"]:
|
||||
return fk.get("name")
|
||||
return None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
for table_name, new_name in _NEW_FK_NAMES.items():
|
||||
existing_name = _existing_task_id_fk_name(bind, table_name)
|
||||
with op.batch_alter_table(table_name) as batch:
|
||||
if existing_name:
|
||||
batch.drop_constraint(existing_name, type_='foreignkey')
|
||||
batch.create_foreign_key(
|
||||
new_name, 'recipe_tasks', ['task_id'], ['id'],
|
||||
ondelete='CASCADE',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Reverse order: reference_images before results, mirroring upgrade().
|
||||
for table_name, name in reversed(list(_NEW_FK_NAMES.items())):
|
||||
with op.batch_alter_table(table_name) as batch:
|
||||
batch.drop_constraint(name, type_='foreignkey')
|
||||
batch.create_foreign_key(
|
||||
name, 'recipe_tasks', ['task_id'], ['id'],
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""API keys as SHA-256 hashes, one per session (#41)
|
||||
|
||||
Replaces the plaintext `users.api_key` column with an `api_keys` table: one
|
||||
row per login, holding only the SHA-256 hex digest, an expiry and the last
|
||||
use. Existing keys are invalidated on purpose, not migrated - the plaintext
|
||||
is exactly what must stop existing, so everybody logs in once again.
|
||||
|
||||
Revision ID: 015_api_key_hash
|
||||
Revises: 014_vision_fk_ondelete
|
||||
Create Date: 2026-10-06
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '015_api_key_hash'
|
||||
down_revision: Union[str, None] = '014_vision_fk_ondelete'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Idempotent on purpose (#71): a database born from create_all has no
|
||||
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
|
||||
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
|
||||
# created and `alembic_version` still at 014. Every step checks first.
|
||||
insp = sa.inspect(op.get_bind())
|
||||
|
||||
if 'api_keys' not in insp.get_table_names():
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||
),
|
||||
)
|
||||
existing_idx: set[str] = set()
|
||||
else:
|
||||
existing_idx = {i['name'] for i in insp.get_indexes('api_keys')}
|
||||
|
||||
if 'ix_api_keys_key_hash' not in existing_idx:
|
||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||
if 'ix_api_keys_user_id' not in existing_idx:
|
||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||
|
||||
if 'api_key' not in {c['name'] for c in insp.get_columns('users')}:
|
||||
return # column already gone: nothing else to do
|
||||
|
||||
user_idx = {i['name'] for i in insp.get_indexes('users')}
|
||||
user_uq = {u['name'] for u in insp.get_unique_constraints('users')}
|
||||
with op.batch_alter_table('users') as batch:
|
||||
if 'ix_users_api_key' in user_idx:
|
||||
batch.drop_index('ix_users_api_key')
|
||||
if 'uq_users_api_key' in user_uq:
|
||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||
batch.drop_column('api_key')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.add_column(sa.Column('api_key', sa.String(64), nullable=True))
|
||||
batch.create_unique_constraint('uq_users_api_key', ['api_key'])
|
||||
batch.create_index('ix_users_api_key', ['api_key'])
|
||||
|
||||
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
|
||||
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
|
||||
op.drop_table('api_keys')
|
||||
@@ -0,0 +1,30 @@
|
||||
"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46)
|
||||
|
||||
Percorso relativo sotto upload_path della cartella immutabile che contiene
|
||||
recipe.json e i file di meta (DXF, modelli .npz, ...).
|
||||
|
||||
Revision ID: 016_vision_recipe_dir
|
||||
Revises: 015_api_key_hash
|
||||
Create Date: 2026-10-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '016_vision_recipe_dir'
|
||||
down_revision: Union[str, None] = '015_api_key_hash'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.drop_column('vision_recipe_dir')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""users: PIN a 4 cifre per login rapido e capoturno (#59)
|
||||
|
||||
pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco.
|
||||
|
||||
Revision ID: 017_user_pin
|
||||
Revises: 016_vision_recipe_dir
|
||||
Create Date: 2026-10-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '017_user_pin'
|
||||
down_revision: Union[str, None] = '016_vision_recipe_dir'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True))
|
||||
batch.add_column(sa.Column(
|
||||
'pin_failed_count', sa.Integer(), nullable=False, server_default='0'))
|
||||
batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.drop_column('pin_locked_until')
|
||||
batch.drop_column('pin_failed_count')
|
||||
batch.drop_column('pin_hash')
|
||||
@@ -0,0 +1,108 @@
|
||||
"""task: in_loop / at_close / fields, tipo 'check', tabella task_results (#53 #54 #55)
|
||||
|
||||
in_loop: il task fa parte del ciclo di produzione (prima: implicito dal tipo).
|
||||
at_close: il task 'note' si compila alla fine produzione.
|
||||
fields: campi extra del task 'note' (JSON).
|
||||
task_results: esito registrato da un task 'check' (OK/NOK) o 'note' (done).
|
||||
|
||||
Revision ID: 018_task_loop_results
|
||||
Revises: 017_user_pin
|
||||
Create Date: 2026-10-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '018_task_loop_results'
|
||||
down_revision: Union[str, None] = '017_user_pin'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
OLD_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
||||
NEW_TYPES = OLD_TYPES + ('check',)
|
||||
# stessi tipi di MEASURING_TASK_TYPES: fino a oggi erano l'unico ciclo possibile
|
||||
LOOP_TYPES = ('measure', 'camera_measure', 'dxf_compare')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.add_column(sa.Column(
|
||||
'in_loop', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
batch.add_column(sa.Column(
|
||||
'at_close', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
batch.add_column(sa.Column('fields', sa.JSON(), nullable=True))
|
||||
# enum allargato come in 011: 'check' = Controllo OK/NOK
|
||||
batch.alter_column(
|
||||
'task_type',
|
||||
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||
existing_nullable=False,
|
||||
existing_server_default='note',
|
||||
)
|
||||
|
||||
# i task di misura erano gia' nel ciclo: lo restano
|
||||
recipe_tasks = sa.table(
|
||||
'recipe_tasks', sa.column('task_type', sa.String), sa.column('in_loop', sa.Boolean))
|
||||
op.execute(
|
||||
recipe_tasks.update()
|
||||
.where(recipe_tasks.c.task_type.in_(LOOP_TYPES))
|
||||
.values(in_loop=True)
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'task_results',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('task_id', sa.Integer(),
|
||||
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('version_id', sa.Integer(),
|
||||
sa.ForeignKey('recipe_versions.id'), nullable=False),
|
||||
sa.Column('production_run_id', sa.Integer(),
|
||||
sa.ForeignKey('production_runs.id', ondelete='SET NULL'), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('outcome', sa.Enum('ok', 'nok', 'done', name='task_outcome_enum'),
|
||||
nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=True),
|
||||
sa.Column('values', sa.JSON(), nullable=True),
|
||||
sa.Column('attachments', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
op.create_index('ix_task_results_task_id', 'task_results', ['task_id'])
|
||||
op.create_index('ix_task_results_version_id', 'task_results', ['version_id'])
|
||||
op.create_index(
|
||||
'ix_task_results_production_run_id', 'task_results', ['production_run_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# il tipo 'check' non esiste prima di 018: se un task lo usa, tornare indietro
|
||||
# lo lascerebbe con un valore fuori enum
|
||||
bind = op.get_bind()
|
||||
used = bind.execute(
|
||||
sa.text("SELECT COUNT(*) FROM recipe_tasks WHERE task_type = 'check'")
|
||||
).scalar()
|
||||
if used:
|
||||
raise RuntimeError(
|
||||
f"Downgrade 018 impossibile: {used} task usano il tipo 'check'. "
|
||||
"Cambiare il tipo o eliminare quei task prima di tornare indietro."
|
||||
)
|
||||
|
||||
op.drop_index('ix_task_results_production_run_id', table_name='task_results')
|
||||
op.drop_index('ix_task_results_version_id', table_name='task_results')
|
||||
op.drop_index('ix_task_results_task_id', table_name='task_results')
|
||||
op.drop_table('task_results')
|
||||
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.alter_column(
|
||||
'task_type',
|
||||
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||
existing_nullable=False,
|
||||
existing_server_default='note',
|
||||
)
|
||||
batch.drop_column('fields')
|
||||
batch.drop_column('at_close')
|
||||
batch.drop_column('in_loop')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""stations.vision_worker_url e vision_results.worker_url (#45)
|
||||
|
||||
stations.vision_worker_url: worker di visione della stazione (NULL = centrale).
|
||||
vision_results.worker_url: worker effettivamente chiamato per quel risultato.
|
||||
|
||||
Revision ID: 019_station_vision_worker
|
||||
Revises: 018_task_loop_results
|
||||
Create Date: 2026-10-11
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '019_station_vision_worker'
|
||||
down_revision: Union[str, None] = '018_task_loop_results'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.add_column(sa.Column('vision_worker_url', sa.String(500), nullable=True))
|
||||
with op.batch_alter_table('vision_results') as batch:
|
||||
batch.add_column(sa.Column('worker_url', sa.String(500), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('vision_results') as batch:
|
||||
batch.drop_column('worker_url')
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.drop_column('vision_worker_url')
|
||||
@@ -0,0 +1,33 @@
|
||||
"""stations.camera_url e stations.camera_source (#72)
|
||||
|
||||
stations.camera_url: servizio camera della stazione (vs-gui o equivalente).
|
||||
stations.camera_source: sorgente da chiedere al servizio, es. camera:<seriale>
|
||||
o camera:folder.
|
||||
|
||||
Revision ID: 020_station_camera
|
||||
Revises: 019_station_vision_worker
|
||||
Create Date: 2026-10-15
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '020_station_camera'
|
||||
down_revision: Union[str, None] = '019_station_vision_worker'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.add_column(sa.Column('camera_url', sa.String(500), nullable=True))
|
||||
batch.add_column(sa.Column('camera_source', sa.String(200), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.drop_column('camera_source')
|
||||
batch.drop_column('camera_url')
|
||||
@@ -1,14 +1,21 @@
|
||||
"""Pydantic schemas for Measurement operations."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class MeasurementCreate(BaseModel):
|
||||
"""Schema for creating a single measurement."""
|
||||
subtask_id: int
|
||||
version_id: int
|
||||
# Finiteness and the DECIMAL(12,6) scale bound are enforced in
|
||||
# measurement_service.save_measurement, not with a Field constraint here:
|
||||
# a NaN/Infinity that fails a pydantic Field constraint gets echoed back
|
||||
# in the validation error's "input", and FastAPI's default error response
|
||||
# then crashes with a 500 trying to JSON-encode that same NaN/Infinity
|
||||
# (json.dumps(allow_nan=False) in Starlette's JSONResponse). A plain
|
||||
# HTTPException raised after parsing sidesteps that.
|
||||
value: float
|
||||
lot_number: Optional[str] = Field(None, max_length=100)
|
||||
serial_number: Optional[str] = Field(None, max_length=100)
|
||||
@@ -47,8 +54,8 @@ class MeasurementResponse(BaseModel):
|
||||
# Who let this value stand, when it was out of tolerance. Null on a value in
|
||||
# tolerance - and on one that is not and is still waiting for an answer.
|
||||
supervisor_id: Optional[int] = None
|
||||
authorised_at: Optional[datetime] = None
|
||||
measured_at: datetime
|
||||
authorised_at: Optional[UtcDateTime] = None
|
||||
measured_at: UtcDateTime
|
||||
synced_to_csv: bool
|
||||
|
||||
|
||||
@@ -64,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):
|
||||
@@ -84,6 +104,21 @@ class TaskProgressListResponse(BaseModel):
|
||||
tasks: list[TaskProgressResponse]
|
||||
|
||||
|
||||
class CycleMeasurementResponse(BaseModel):
|
||||
"""One quote already measured in the cycle under way."""
|
||||
|
||||
subtask_id: int
|
||||
value: float
|
||||
pass_fail: str
|
||||
measured_at: UtcDateTime
|
||||
|
||||
|
||||
class CurrentCycleResponse(BaseModel):
|
||||
"""The quotes to restore on the screen after a reload; empty if the task is closed."""
|
||||
|
||||
measurements: list[CycleMeasurementResponse]
|
||||
|
||||
|
||||
class MeasurementQuery(BaseModel):
|
||||
"""Schema for measurement query filters."""
|
||||
recipe_id: Optional[int] = None
|
||||
@@ -92,8 +127,8 @@ class MeasurementQuery(BaseModel):
|
||||
measured_by: Optional[int] = None
|
||||
lot_number: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
date_from: UtcNaiveInput = None
|
||||
date_to: UtcNaiveInput = None
|
||||
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
||||
page: int = Field(1, ge=1)
|
||||
per_page: int = Field(50, ge=1, le=500)
|
||||
|
||||
@@ -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
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Pydantic schemas for production runs and their events."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class ProductionRunCreate(BaseModel):
|
||||
@@ -21,7 +21,7 @@ class ProductionEventResponse(BaseModel):
|
||||
supervisor_id: Optional[int]
|
||||
task_id: Optional[int] = None
|
||||
note: Optional[str]
|
||||
created_at: datetime
|
||||
created_at: UtcDateTime
|
||||
|
||||
|
||||
class ProductionRunResponse(BaseModel):
|
||||
@@ -36,11 +36,11 @@ class ProductionRunResponse(BaseModel):
|
||||
serial_number: Optional[str]
|
||||
status: str
|
||||
measurement_interval_minutes: Optional[int]
|
||||
next_measurement_at: Optional[datetime]
|
||||
next_measurement_at: Optional[UtcDateTime]
|
||||
cycle_count: int
|
||||
started_at: datetime
|
||||
paused_at: Optional[datetime]
|
||||
closed_at: Optional[datetime]
|
||||
started_at: UtcDateTime
|
||||
paused_at: Optional[UtcDateTime]
|
||||
closed_at: Optional[UtcDateTime]
|
||||
closed_by: Optional[int]
|
||||
statistics_path: Optional[str] = None
|
||||
|
||||
@@ -49,12 +49,14 @@ class ProductionRunResponse(BaseModel):
|
||||
# overdue is a fact the operator must see, not deduce.
|
||||
seconds_to_next_measurement: Optional[int] = None
|
||||
overdue: bool = False
|
||||
server_time: Optional[datetime] = None
|
||||
server_time: Optional[UtcDateTime] = None
|
||||
|
||||
# The measurement tasks of this run's version, in execution order. The client
|
||||
# needs them to bring the operator back to the measurement when the interval
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for Recipe and RecipeVersion operations."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -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)
|
||||
@@ -51,7 +57,7 @@ class RecipeVersionResponse(BaseModel):
|
||||
version_number: int
|
||||
is_current: bool
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
created_at: UtcDateTime
|
||||
change_notes: Optional[str] = None
|
||||
tasks: list["TaskResponse"] = []
|
||||
|
||||
@@ -72,7 +78,7 @@ class RecipeResponse(BaseModel):
|
||||
requires_serial: bool = False
|
||||
allow_manual_input: bool = False
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
created_at: UtcDateTime
|
||||
active: bool
|
||||
current_version: Optional[RecipeVersionResponse] = None
|
||||
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
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,8 +75,11 @@ 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: datetime
|
||||
created_at: UtcDateTime
|
||||
|
||||
|
||||
class StationRecipeAssignmentCreate(BaseModel):
|
||||
@@ -42,7 +92,7 @@ class StationRecipeAssignmentResponse(BaseModel):
|
||||
station_id: int
|
||||
recipe_id: int
|
||||
assigned_by: int
|
||||
assigned_at: datetime
|
||||
assigned_at: UtcDateTime
|
||||
|
||||
|
||||
class RecipeSummary(BaseModel):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for SPC statistics responses."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
|
||||
recipe_id: int
|
||||
version_id: Optional[int] = None
|
||||
subtask_id: Optional[int] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
date_from: UtcNaiveInput = None
|
||||
date_to: UtcNaiveInput = None
|
||||
operator_id: Optional[int] = None
|
||||
lot_number: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
|
||||
class ControlChartData(BaseModel):
|
||||
"""Data for X-bar/R control chart."""
|
||||
values: list[float]
|
||||
timestamps: list[datetime]
|
||||
timestamps: list[UtcDateTime]
|
||||
mean: float
|
||||
ucl: float # Upper Control Limit
|
||||
lcl: float # Lower Control Limit
|
||||
@@ -50,7 +50,8 @@ class CapabilityData(BaseModel):
|
||||
pp: Optional[float] = None
|
||||
ppk: Optional[float] = None
|
||||
mean: float
|
||||
std_dev: float
|
||||
std_dev: float # overall sample std dev (n-1), used for Pp/Ppk
|
||||
std_dev_within: Optional[float] = None # MRbar/1.128, used for Cp/Cpk
|
||||
n: int
|
||||
utl: Optional[float] = None
|
||||
ltl: Optional[float] = None
|
||||
@@ -59,7 +60,7 @@ class CapabilityData(BaseModel):
|
||||
|
||||
class TrendData(BaseModel):
|
||||
"""Temporal trend data for capability indices."""
|
||||
dates: list[datetime]
|
||||
dates: list[UtcDateTime]
|
||||
cpk_values: list[Optional[float]]
|
||||
ppk_values: list[Optional[float]]
|
||||
n_per_period: list[int]
|
||||
|
||||
@@ -1,14 +1,110 @@
|
||||
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
||||
import re
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from src.backend.models.orm.task import TASK_TYPES
|
||||
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]
|
||||
|
||||
|
||||
def validate_tolerance_order(
|
||||
ltl: Optional[float],
|
||||
lwl: Optional[float],
|
||||
nominal: Optional[float],
|
||||
uwl: Optional[float],
|
||||
utl: Optional[float],
|
||||
) -> None:
|
||||
"""ltl <= lwl <= nominal <= uwl <= utl, checked only across the values set.
|
||||
|
||||
A one-sided tolerance - only utl, say - is not an error: what is wrong is
|
||||
limits that invert the sequence they do share, like utl landing below ltl.
|
||||
Shared by SubtaskCreate's own validator and by the router's partial-update
|
||||
check (SubtaskUpdate has no fixed set of fields to compare).
|
||||
"""
|
||||
ordered = [
|
||||
("ltl", ltl), ("lwl", lwl), ("nominal", nominal), ("uwl", uwl), ("utl", utl),
|
||||
]
|
||||
present = [(name, value) for name, value in ordered if value is not None]
|
||||
for (prev_name, prev), (name, value) in zip(present, present[1:]):
|
||||
if prev > value:
|
||||
raise ValueError(
|
||||
f"Tolerance limits out of order: {prev_name}={prev} > {name}={value}"
|
||||
)
|
||||
|
||||
|
||||
# #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)
|
||||
@@ -23,6 +119,11 @@ class SubtaskCreate(BaseModel):
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
vision_output: Optional[str] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_tolerance_order(self) -> "SubtaskCreate":
|
||||
validate_tolerance_order(self.ltl, self.lwl, self.nominal, self.uwl, self.utl)
|
||||
return self
|
||||
|
||||
|
||||
class SubtaskUpdate(BaseModel):
|
||||
"""Schema for updating a subtask."""
|
||||
@@ -68,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] = []
|
||||
|
||||
|
||||
@@ -81,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):
|
||||
@@ -99,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
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Shared Pydantic types for datetimes (#42): UTC in the DB, explicit +00:00 on the wire."""
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from pydantic import AfterValidator, PlainSerializer
|
||||
|
||||
from src.backend.utils.tempo import as_utc_naive, to_utc_aware
|
||||
|
||||
UtcDateTime = Annotated[
|
||||
datetime, PlainSerializer(lambda v: to_utc_aware(v).isoformat(), return_type=str)
|
||||
]
|
||||
|
||||
# Input: aware values are converted to UTC naive, naive is taken as UTC.
|
||||
UtcNaiveInput = Annotated[Optional[datetime], AfterValidator(as_utc_naive)]
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for User operations."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -48,8 +48,10 @@ class UserResponse(BaseModel):
|
||||
language_pref: str
|
||||
theme_pref: str
|
||||
active: bool
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
created_at: UtcDateTime
|
||||
last_login: Optional[UtcDateTime] = None
|
||||
# #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}$")
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Pydantic schemas for the vision execution response."""
|
||||
from pydantic import BaseModel
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class VisionMeasurementResult(BaseModel):
|
||||
@@ -14,3 +16,44 @@ 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):
|
||||
"""One saved reference image, as `GET /api/vision/reference-images` lists it."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
task_id: int
|
||||
path: str
|
||||
note: str | None = None
|
||||
station_id: int | None = None
|
||||
device_code: str | None = None
|
||||
acquired_at: UtcDateTime
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""SQLAlchemy models for TieMeasureFlow."""
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.models.orm.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
|
||||
@@ -11,10 +13,12 @@ from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"ApiKey",
|
||||
"Recipe",
|
||||
"RecipeVersion",
|
||||
"RecipeTask",
|
||||
"RecipeSubtask",
|
||||
"TaskResult",
|
||||
"Measurement",
|
||||
"AccessLog",
|
||||
"SystemSetting",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""API key sessions: one row per login, stored as a SHA-256 hash."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""One API key per login session.
|
||||
|
||||
Only the SHA-256 hex digest is stored, never the key. Dates are naive UTC.
|
||||
"""
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
@@ -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"
|
||||
@@ -32,7 +39,6 @@ class User(Base):
|
||||
|
||||
# Status
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
api_key: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
@@ -40,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 [])
|
||||
|
||||
@@ -13,7 +13,8 @@ class VisionResult(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
||||
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
@@ -25,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)
|
||||
@@ -48,7 +51,8 @@ class VisionReferenceImage(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
||||
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
station_id: Mapped[Optional[int]] = mapped_column(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""AI service for parsing technical sheets via OpenRouter."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from io import BytesIO
|
||||
@@ -10,6 +11,17 @@ from src.backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIResponseError(Exception):
|
||||
"""The AI model's response could not be used: missing fields or invalid JSON.
|
||||
|
||||
Distinct from ValueError (raised for our own misconfiguration, e.g. a
|
||||
missing API key) so the router can tell "we are broken" (400) apart from
|
||||
"the upstream AI service is broken" (502) instead of both landing on the
|
||||
same except clause.
|
||||
"""
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """Sei un assistente specializzato nell'analisi di schede tecniche industriali.
|
||||
Analizza il testo estratto da una scheda tecnica e identifica i blocchi operativi distinti.
|
||||
Per ogni blocco, estrai:
|
||||
@@ -36,7 +48,9 @@ def extract_text_from_pdf(pdf_bytes: bytes) -> str:
|
||||
|
||||
|
||||
async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
||||
text = extract_text_from_pdf(pdf_bytes)
|
||||
# pdfplumber parses synchronously and can take a while on a big PDF: off
|
||||
# the event loop, or one upload stalls every other request being served.
|
||||
text = await asyncio.to_thread(extract_text_from_pdf, pdf_bytes)
|
||||
if not text.strip():
|
||||
return []
|
||||
|
||||
@@ -62,7 +76,10 @@ async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"].strip()
|
||||
try:
|
||||
content = data["choices"][0]["message"]["content"].strip()
|
||||
except (KeyError, IndexError, TypeError, AttributeError) as e:
|
||||
raise AIResponseError(f"Risposta del modello AI malformata: {e}") from e
|
||||
|
||||
# Strip markdown fences if present
|
||||
if content.startswith("```"):
|
||||
@@ -71,4 +88,10 @@ async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
||||
content = content[:-3]
|
||||
content = content.strip()
|
||||
|
||||
return json.loads(content)
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError as e:
|
||||
# json.JSONDecodeError is itself a ValueError: without this, the
|
||||
# router's `except ValueError` (meant for our own config errors)
|
||||
# would misreport a broken upstream response as our fault (400).
|
||||
raise AIResponseError(f"Risposta del modello AI non e' JSON valido: {e}") from e
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"""Authentication service - password hashing, API key management."""
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from src.backend.utils.tempo import utcnow
|
||||
|
||||
import bcrypt
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
|
||||
@@ -27,6 +31,13 @@ def generate_api_key() -> str:
|
||||
return secrets.token_urlsafe(48) # 64 chars base64
|
||||
|
||||
|
||||
# A precomputed, unused bcrypt hash. When the username does not match any
|
||||
# user, authenticate_user still runs bcrypt against this instead of
|
||||
# returning immediately, so "unknown username" costs the same as "wrong
|
||||
# password" and a caller cannot use response time to enumerate usernames.
|
||||
_DUMMY_PASSWORD_HASH = "$2b$12$BLtwrR2COyd5W/UWfxp7BOc7OHua9jazo6zZdWOwRD9PV.pyaqKZC"
|
||||
|
||||
|
||||
async def authenticate_user(
|
||||
db: AsyncSession, username: str, password: str
|
||||
) -> User | None:
|
||||
@@ -35,12 +46,87 @@ async def authenticate_user(
|
||||
select(User).where(User.username == username, User.active == True)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
if user is None:
|
||||
verify_password(password, _DUMMY_PASSWORD_HASH)
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
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
|
||||
@@ -48,10 +134,19 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
||||
of them because it is the same question every time - who is this, and are they
|
||||
allowed to say yes.
|
||||
|
||||
Uses authenticate_user rather than a full login: logging in would rotate the
|
||||
supervisor's API key and knock out whatever session they have open elsewhere.
|
||||
Uses authenticate_user rather than a full login: logging in would issue a
|
||||
needless API key for the supervisor.
|
||||
"""
|
||||
supervisor = await authenticate_user(db, username, password)
|
||||
if 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,
|
||||
@@ -65,24 +160,67 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
||||
return supervisor
|
||||
|
||||
|
||||
async def login_user(db: AsyncSession, user: User) -> str:
|
||||
"""Generate API key and update last_login for user."""
|
||||
api_key = generate_api_key()
|
||||
def hash_api_key(key: str) -> str:
|
||||
"""SHA-256 hex digest of an API key; this is all the DB ever stores."""
|
||||
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def issue_api_key(db: AsyncSession, user: User) -> str:
|
||||
"""Create a new session key for the user and return it in clear (once)."""
|
||||
key = generate_api_key()
|
||||
now = utcnow()
|
||||
# Housekeeping: drop this user's expired keys so the table does not grow forever.
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(api_key=api_key, last_login=datetime.utcnow())
|
||||
delete(ApiKey).where(ApiKey.user_id == user.id, ApiKey.expires_at <= now)
|
||||
)
|
||||
db.add(
|
||||
ApiKey(
|
||||
user_id=user.id,
|
||||
key_hash=hash_api_key(key),
|
||||
created_at=now,
|
||||
expires_at=now + timedelta(hours=settings.api_key_ttl_hours),
|
||||
last_used_at=now,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return key
|
||||
|
||||
|
||||
async def revoke_all_keys(db: AsyncSession, user_id: int) -> None:
|
||||
"""Invalidate every session key of a user."""
|
||||
await db.execute(delete(ApiKey).where(ApiKey.user_id == user_id))
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def login_user(db: AsyncSession, user: User) -> str:
|
||||
"""Issue a new API key for this login and update last_login.
|
||||
|
||||
One key per login: the same account used from several stations at once
|
||||
(#25) keeps every station working, because no login revokes another's key.
|
||||
"""
|
||||
api_key = await issue_api_key(db, user)
|
||||
await db.execute(
|
||||
update(User).where(User.id == user.id).values(last_login=utcnow())
|
||||
)
|
||||
await db.flush()
|
||||
return api_key
|
||||
|
||||
|
||||
async def logout_user(db: AsyncSession, user: User) -> None:
|
||||
"""Invalidate user's API key."""
|
||||
await db.execute(
|
||||
update(User).where(User.id == user.id).values(api_key=None)
|
||||
)
|
||||
await db.flush()
|
||||
async def logout_user(
|
||||
db: AsyncSession, user: User, key: str | None = None, all_sessions: bool = False
|
||||
) -> None:
|
||||
"""Log out. Deletes only the key of this session; all_sessions=True
|
||||
deletes every key of the user."""
|
||||
if all_sessions:
|
||||
await revoke_all_keys(db, user.id)
|
||||
return
|
||||
if key:
|
||||
await db.execute(
|
||||
delete(ApiKey).where(
|
||||
ApiKey.user_id == user.id, ApiKey.key_hash == hash_api_key(key)
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def create_user(
|
||||
@@ -113,11 +251,7 @@ async def create_user(
|
||||
return user
|
||||
|
||||
|
||||
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
|
||||
"""Regenerate API key for a user."""
|
||||
new_key = generate_api_key()
|
||||
await db.execute(
|
||||
update(User).where(User.id == user_id).values(api_key=new_key)
|
||||
)
|
||||
await db.flush()
|
||||
return new_key
|
||||
async def regenerate_api_key(db: AsyncSession, user: User) -> str:
|
||||
"""Revoke every key of the user and issue a fresh one."""
|
||||
await revoke_all_keys(db, user.id)
|
||||
return await issue_api_key(db, user)
|
||||
|
||||
@@ -1,18 +1,54 @@
|
||||
"""Measurement service - pass/fail calculation, data storage."""
|
||||
import math
|
||||
from datetime import datetime
|
||||
from src.backend.utils.tempo import utcnow
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionEvent
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import (
|
||||
MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask,
|
||||
)
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
|
||||
|
||||
# What the Measurement.value column (DECIMAL(12,6)) can actually hold.
|
||||
_MAX_ABS_VALUE = 1_000_000
|
||||
|
||||
|
||||
def _validate_measured_value(value: float) -> None:
|
||||
"""Refuse a value that would corrupt the verdict or the INSERT.
|
||||
|
||||
NaN makes every tolerance comparison false, so a bad reading silently
|
||||
comes out as "pass"; NaN and Infinity then both fail the DECIMAL(12,6)
|
||||
column at insert time with an IntegrityError the operator never asked
|
||||
for. Raised as a plain HTTPException with a string detail rather than as
|
||||
a pydantic Field constraint on the request model: a Field rejecting NaN
|
||||
(via allow_inf_nan or a gt/lt bound) makes pydantic echo that same NaN
|
||||
back in the validation error's "input", and FastAPI's default error
|
||||
response then crashes with a 500 trying to JSON-encode it
|
||||
(Starlette's JSONResponse calls json.dumps(..., allow_nan=False)).
|
||||
"""
|
||||
if not math.isfinite(value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Measured value must be a finite number (no NaN/Infinity)",
|
||||
)
|
||||
if abs(value) >= _MAX_ABS_VALUE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Measured value out of range: |value| must be < {_MAX_ABS_VALUE}",
|
||||
)
|
||||
|
||||
|
||||
def calculate_pass_fail(
|
||||
value: float, subtask: RecipeSubtask
|
||||
@@ -25,7 +61,12 @@ def calculate_pass_fail(
|
||||
- If value is outside UTL/LTL -> 'fail'
|
||||
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
|
||||
- Otherwise -> 'pass'
|
||||
|
||||
The value is rounded to 6 decimals first - the precision the DECIMAL(12,6)
|
||||
column keeps - so the verdict is computed on the same number that ends up
|
||||
stored, and a value like 10.5000004 does not fail against a 10.5 limit.
|
||||
"""
|
||||
value = round(value, 6)
|
||||
deviation = None
|
||||
if subtask.nominal is not None:
|
||||
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
|
||||
@@ -202,12 +243,108 @@ async def task_progress(
|
||||
return progress
|
||||
|
||||
|
||||
async def current_cycle_measurements(
|
||||
db: AsyncSession, run_id: int, task_id: int,
|
||||
) -> list[dict]:
|
||||
"""The quotes of a task already measured in the cycle that is under way.
|
||||
|
||||
A reload, a timer redirect or an auto-logout throws away what the page held in
|
||||
memory; the operator would measure the same quotes again and the duplicates
|
||||
would reach the SPC statistics. The cycle needs no column of its own: it starts
|
||||
at the latest event of the run that opens one for this task - the run start, a
|
||||
cycle_completed, or a remeasure of this task.
|
||||
|
||||
If a task_measured of this task follows that boundary the task is already
|
||||
closed in this cycle and there is nothing to restore. Where a quote was measured
|
||||
more than once, the last reading stands.
|
||||
"""
|
||||
boundary = (await db.execute(
|
||||
select(ProductionEvent.id, ProductionEvent.created_at)
|
||||
.where(
|
||||
ProductionEvent.run_id == run_id,
|
||||
(ProductionEvent.event_type.in_(("start", "cycle_completed")))
|
||||
| (
|
||||
(ProductionEvent.event_type == "remeasure")
|
||||
& (ProductionEvent.task_id == task_id)
|
||||
),
|
||||
)
|
||||
.order_by(ProductionEvent.id.desc())
|
||||
.limit(1)
|
||||
)).first()
|
||||
|
||||
event_filter = [ProductionEvent.run_id == run_id]
|
||||
measurement_filter = [Measurement.production_run_id == run_id]
|
||||
if boundary is not None:
|
||||
boundary_id, boundary_at = boundary
|
||||
event_filter.append(ProductionEvent.id > boundary_id)
|
||||
measurement_filter.append(Measurement.measured_at > boundary_at)
|
||||
|
||||
closed = (await db.execute(
|
||||
select(ProductionEvent.id).where(
|
||||
*event_filter,
|
||||
ProductionEvent.event_type == "task_measured",
|
||||
ProductionEvent.task_id == task_id,
|
||||
).limit(1)
|
||||
)).first()
|
||||
if closed is not None:
|
||||
return []
|
||||
|
||||
rows = (await db.execute(
|
||||
select(Measurement)
|
||||
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id)
|
||||
.where(*measurement_filter, RecipeSubtask.task_id == task_id)
|
||||
.order_by(Measurement.measured_at, Measurement.id)
|
||||
)).scalars().all()
|
||||
|
||||
latest: dict[int, Measurement] = {}
|
||||
for m in rows:
|
||||
latest[m.subtask_id] = m
|
||||
return [
|
||||
{
|
||||
"subtask_id": m.subtask_id,
|
||||
"value": float(m.value),
|
||||
"pass_fail": m.pass_fail,
|
||||
"measured_at": m.measured_at,
|
||||
}
|
||||
for m in sorted(latest.values(), key=lambda m: (m.measured_at, m.id))
|
||||
]
|
||||
|
||||
|
||||
async def validate_run_for_version(
|
||||
db: AsyncSession, run_id: int, version_id: int,
|
||||
) -> "ProductionRun":
|
||||
"""The run a measurement is being filed against, checked for coherence.
|
||||
|
||||
Three things have to hold before a value goes in against a production: the
|
||||
run has to exist, it has to belong to the same recipe as the version being
|
||||
measured (a run started for another product must not collect another
|
||||
product's history), and it has to be in a state that accepts measurements
|
||||
(not closed, not paused). Shared with vision_service, which files camera
|
||||
measurements against a run the same way a manual reading does.
|
||||
"""
|
||||
# Local import: production_service imports this module at module scope,
|
||||
# so importing it back here at module scope would be circular.
|
||||
from src.backend.services import production_service
|
||||
|
||||
run = await production_service.get_run(db, run_id) # 404 if missing
|
||||
|
||||
recipe = await recipe_of_version(db, version_id)
|
||||
if recipe is None or run.recipe_id != recipe.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Production run belongs to a different recipe than this version",
|
||||
)
|
||||
|
||||
production_service._require_measurable(run) # 409 if closed or paused
|
||||
return run
|
||||
|
||||
|
||||
async def authorise_measurement(
|
||||
db: AsyncSession, measurement: Measurement, supervisor: User,
|
||||
) -> Measurement:
|
||||
"""Let an out-of-tolerance value stand, on the supervisor's name."""
|
||||
measurement.supervisor_id = supervisor.id
|
||||
measurement.authorised_at = datetime.now()
|
||||
measurement.authorised_at = utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(measurement)
|
||||
return measurement
|
||||
@@ -236,6 +373,8 @@ async def save_measurement(
|
||||
the gate models "moving on" between measurements taken one after another,
|
||||
which does not exist inside a single camera acquisition.
|
||||
"""
|
||||
_validate_measured_value(value)
|
||||
|
||||
# Get subtask for tolerance values
|
||||
result = await db.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||
@@ -244,6 +383,22 @@ async def save_measurement(
|
||||
if subtask is None:
|
||||
raise ValueError(f"Subtask {subtask_id} not found")
|
||||
|
||||
# The quote has to belong to a task of the declared version - otherwise the
|
||||
# tolerances just read off `subtask` are not the ones this version's operator
|
||||
# was shown, and pass/fail is computed against the wrong recipe.
|
||||
task_row = await db.execute(
|
||||
select(RecipeTask.version_id).where(RecipeTask.id == subtask.task_id)
|
||||
)
|
||||
subtask_version_id = task_row.scalar_one_or_none()
|
||||
if subtask_version_id != version_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Subtask does not belong to the given version",
|
||||
)
|
||||
|
||||
if production_run_id is not None:
|
||||
await validate_run_for_version(db, production_run_id, version_id)
|
||||
|
||||
recipe = await recipe_of_version(db, version_id)
|
||||
_enforce_recipe_rules(recipe, lot_number, serial_number, input_method)
|
||||
|
||||
@@ -263,6 +418,10 @@ async def save_measurement(
|
||||
),
|
||||
)
|
||||
|
||||
# Rounded once, here, so the stored value is exactly the one the verdict
|
||||
# above was computed on - calculate_pass_fail rounds internally too, but
|
||||
# matching it here keeps the persisted row from disagreeing with itself.
|
||||
value = round(value, 6)
|
||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||
|
||||
measurement = Measurement(
|
||||
|
||||
@@ -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
|
||||
@@ -9,9 +9,12 @@ The CSV uses the delimiters configured in system_settings, the same ones the man
|
||||
export honours, so a shop floor set to the Italian convention (';' and ',') gets
|
||||
files that open correctly in their Excel.
|
||||
"""
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from src.backend.utils.tempo import iso_utc, to_local, utcnow
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, update
|
||||
@@ -25,10 +28,38 @@ from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Written under the upload directory: it is the volume that is already mounted and
|
||||
# backed up, so the files survive a container being replaced.
|
||||
EXPORT_SUBDIR = "statistics"
|
||||
|
||||
# `csv.writer` requires a 1-character delimiter. PUT /api/settings/ validates this
|
||||
# going forward (gitea#23), but a value written before that guard existed must not
|
||||
# be able to make close_run fail: fall back and log instead of raising.
|
||||
_VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
|
||||
_FALLBACK_CSV_DELIMITER = ";"
|
||||
|
||||
# A cell starting with one of these (after leading spaces) is read as a formula
|
||||
# by Excel/LibreOffice when the CSV is opened — CSV/formula injection (OWASP).
|
||||
_DANGEROUS_CSV_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
||||
|
||||
|
||||
def neutralise_csv_cell(value: str) -> str:
|
||||
"""Defuse a formula-injection payload in a text CSV cell.
|
||||
|
||||
Prepends a single quote when the value, stripped of leading spaces, starts
|
||||
with =, +, -, @, TAB or CR: this keeps spreadsheet software from evaluating
|
||||
it as a formula while the value stays valid, literal CSV text. Only for
|
||||
free-text fields (lot, serial, descriptions, names, codes) — never call
|
||||
this on numeric cells, the quote would make them text.
|
||||
"""
|
||||
stripped = value.lstrip(" ")
|
||||
if stripped and stripped[0] in _DANGEROUS_CSV_PREFIXES:
|
||||
return "'" + value
|
||||
return value
|
||||
|
||||
|
||||
HEADER = [
|
||||
"production_run_id",
|
||||
"station_code",
|
||||
@@ -75,8 +106,17 @@ async def _csv_format(db: AsyncSession) -> tuple[str, str]:
|
||||
)
|
||||
)
|
||||
decimal = decimal_row.scalar_one_or_none()
|
||||
|
||||
delimiter_value = delimiter.setting_value if delimiter else ","
|
||||
if delimiter_value not in _VALID_CSV_DELIMITERS:
|
||||
logger.warning(
|
||||
"csv_delimiter non valido nel DB (%r): uso %r",
|
||||
delimiter_value, _FALLBACK_CSV_DELIMITER,
|
||||
)
|
||||
delimiter_value = _FALLBACK_CSV_DELIMITER
|
||||
|
||||
return (
|
||||
delimiter.setting_value if delimiter else ",",
|
||||
delimiter_value,
|
||||
decimal.setting_value if decimal else ".",
|
||||
)
|
||||
|
||||
@@ -109,15 +149,15 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
||||
for measurement, subtask in rows:
|
||||
writer.writerow([
|
||||
run.id,
|
||||
station.code if station else "",
|
||||
recipe.code if recipe else "",
|
||||
neutralise_csv_cell(station.code) if station else "",
|
||||
neutralise_csv_cell(recipe.code) if recipe else "",
|
||||
measurement.version_id,
|
||||
measurement.lot_number or "",
|
||||
measurement.serial_number or "",
|
||||
neutralise_csv_cell(measurement.lot_number or ""),
|
||||
neutralise_csv_cell(measurement.serial_number or ""),
|
||||
measurement.id,
|
||||
measurement.subtask_id,
|
||||
subtask.marker_number if subtask else "",
|
||||
subtask.description if subtask else "",
|
||||
neutralise_csv_cell(subtask.description) if subtask else "",
|
||||
number(subtask.nominal) if subtask else "",
|
||||
number(measurement.value),
|
||||
number(measurement.deviation),
|
||||
@@ -130,9 +170,9 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
||||
measurement.input_method,
|
||||
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
||||
measurement.measured_by,
|
||||
measurement.measured_at.isoformat() if measurement.measured_at else "",
|
||||
iso_utc(measurement.measured_at),
|
||||
measurement.supervisor_id or "",
|
||||
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
|
||||
iso_utc(measurement.authorised_at),
|
||||
])
|
||||
|
||||
return output.getvalue()
|
||||
@@ -152,9 +192,13 @@ async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | N
|
||||
directory = Path(settings.upload_path) / EXPORT_SUBDIR
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
|
||||
stamp = to_local(run.closed_at or utcnow()).strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"production_{run.id}_{stamp}.csv"
|
||||
(directory / filename).write_text(content, encoding="utf-8-sig")
|
||||
# A large run's file can take a moment to write: off the event loop, so
|
||||
# closing one run does not stall every other request being served.
|
||||
await asyncio.to_thread(
|
||||
(directory / filename).write_text, content, encoding="utf-8-sig"
|
||||
)
|
||||
|
||||
# utf-8-sig: the BOM is what makes Excel read accented characters correctly,
|
||||
# and these files are opened in Excel on the shop floor.
|
||||
|
||||
@@ -14,6 +14,7 @@ the countdown would be off by the UTC offset. The number is the contract; the
|
||||
timestamp is only there for the audit trail.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from src.backend.utils.tempo import utcnow
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
@@ -23,7 +24,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.backend.models.api.production import ProductionRunCreate
|
||||
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.models.orm.task import MEASURING_TASK_TYPES, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import (
|
||||
@@ -34,10 +35,10 @@ from src.backend.services import (
|
||||
def _now() -> datetime:
|
||||
"""Single source of time for runs.
|
||||
|
||||
Naive local time, matching what the database writes for ``func.now()`` defaults,
|
||||
Naive UTC, matching what the database writes for ``func.now()`` defaults,
|
||||
so a run's started_at and its deadline are comparable.
|
||||
"""
|
||||
return datetime.now()
|
||||
return utcnow()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -106,6 +107,29 @@ async def get_station_by_code(db: AsyncSession, code: str) -> Station:
|
||||
return station
|
||||
|
||||
|
||||
async def _require_recipe_assigned_to_station(
|
||||
db: AsyncSession, station_id: int, recipe_id: int,
|
||||
) -> None:
|
||||
"""A configured station only runs what it is assigned; an unconfigured one is free.
|
||||
|
||||
Mirrors station_service.list_station_recipes, the read side an operator's
|
||||
recipe list is already filtered by: a station with at least one assignment
|
||||
restricts to those recipes, one with none has not been set up to filter at
|
||||
all and stays open to any active recipe.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(StationRecipeAssignment.recipe_id).where(
|
||||
StationRecipeAssignment.station_id == station_id,
|
||||
)
|
||||
)
|
||||
assigned_ids = set(result.scalars().all())
|
||||
if assigned_ids and recipe_id not in assigned_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="This recipe is not assigned to this station",
|
||||
)
|
||||
|
||||
|
||||
async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||
"""Ids of the measurement tasks of a version, in the order they are executed.
|
||||
|
||||
@@ -121,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)
|
||||
)
|
||||
@@ -199,6 +237,12 @@ async def open_run(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
|
||||
)
|
||||
if not recipe.active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot start a production run with an inactive recipe",
|
||||
)
|
||||
await _require_recipe_assigned_to_station(db, station.id, recipe.id)
|
||||
|
||||
if data.version_id is not None:
|
||||
version_row = await db.execute(
|
||||
|
||||
@@ -12,6 +12,7 @@ from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.setting import RecipeVersionAudit
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.vision import VisionReferenceImage
|
||||
from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
||||
|
||||
|
||||
@@ -34,13 +35,34 @@ HEADER_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
# image_path and measurement_interval_minutes are nullable columns where the
|
||||
# caller may deliberately want to clear the value (send null), not merely
|
||||
# leave it untouched by not mentioning the field at all. A plain
|
||||
# "getattr(...) is not None" check cannot tell those two cases apart -
|
||||
# once set, neither field could ever be cleared again (#37.3). The other
|
||||
# header fields keep that simpler check: booleans are always sent by the
|
||||
# client (never null) and clearing name/description to null is not a case
|
||||
# this endpoint needs to support.
|
||||
_NULLABLE_HEADER_FIELDS = ("image_path", "measurement_interval_minutes")
|
||||
|
||||
|
||||
def _header_fields(data: RecipeUpdate) -> dict:
|
||||
"""The header columns the caller actually asked to change."""
|
||||
return {
|
||||
field: getattr(data, field)
|
||||
for field in HEADER_FIELDS
|
||||
if getattr(data, field) is not None
|
||||
}
|
||||
"""The header columns the caller actually asked to change.
|
||||
|
||||
For image_path/measurement_interval_minutes, "asked to change" includes
|
||||
explicitly setting it to null - told apart from "field absent from the
|
||||
request" via model_fields_set, since RecipeUpdate defaults both to None.
|
||||
"""
|
||||
result = {}
|
||||
for field in HEADER_FIELDS:
|
||||
if field in _NULLABLE_HEADER_FIELDS:
|
||||
if field in data.model_fields_set:
|
||||
result[field] = getattr(data, field)
|
||||
else:
|
||||
value = getattr(data, field)
|
||||
if value is not None:
|
||||
result[field] = value
|
||||
return result
|
||||
|
||||
|
||||
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
|
||||
@@ -93,10 +115,38 @@ 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
|
||||
|
||||
# Reference images (#38.6): the copied task points at the same files
|
||||
# on disk as the source - same `path`, same acquisition metadata. A
|
||||
# graph composed against a reference must keep working after the
|
||||
# recipe is next edited, exactly like task_type/vision_json above.
|
||||
# The file itself is only ever removed once no row references its
|
||||
# path any more (see vision_service.file_still_referenced).
|
||||
ref_images = (await db.execute(
|
||||
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
|
||||
)).scalars().all()
|
||||
for ref in ref_images:
|
||||
db.add(VisionReferenceImage(
|
||||
task_id=new_task.id,
|
||||
path=ref.path,
|
||||
station_id=ref.station_id,
|
||||
device_code=ref.device_code,
|
||||
calibration_snapshot=ref.calibration_snapshot,
|
||||
engine_version=ref.engine_version,
|
||||
note=ref.note,
|
||||
expected_json=ref.expected_json,
|
||||
acquired_at=ref.acquired_at,
|
||||
))
|
||||
|
||||
for sub in task.subtasks:
|
||||
new_sub = RecipeSubtask(
|
||||
task_id=new_task.id,
|
||||
@@ -209,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,
|
||||
@@ -425,6 +547,7 @@ async def update_current_version(
|
||||
default_task = RecipeTask(
|
||||
version_id=current.id,
|
||||
order_index=0,
|
||||
task_type="drawing",
|
||||
title="Technical Drawing",
|
||||
file_path=data.file_path,
|
||||
file_type=data.file_type or (
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""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
|
||||
|
||||
import plotly.graph_objects as go
|
||||
@@ -23,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(
|
||||
@@ -31,6 +51,11 @@ _jinja_env = Environment(
|
||||
)
|
||||
|
||||
|
||||
def _giorno_locale(dt: datetime) -> str:
|
||||
"""UTC naive filter bound -> local calendar day (YYYY-MM-DD)."""
|
||||
return to_local(dt).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
async def _query_measurements(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
@@ -41,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 = []
|
||||
@@ -66,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)
|
||||
@@ -204,6 +233,11 @@ def _generate_control_chart_svg(chart_data, subtask_info: dict | None) -> str:
|
||||
return svg_bytes.decode("utf-8")
|
||||
|
||||
|
||||
def _render_pdf(html_content: str) -> bytes:
|
||||
"""Blocking WeasyPrint render — always call via asyncio.to_thread."""
|
||||
return HTML(string=html_content).write_pdf()
|
||||
|
||||
|
||||
def _generate_histogram_svg(hist_data, subtask_info: dict | None) -> str:
|
||||
"""Generate histogram SVG using Plotly + Kaleido."""
|
||||
if not hist_data.bins:
|
||||
@@ -269,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.
|
||||
|
||||
@@ -278,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
|
||||
@@ -300,9 +336,13 @@ async def generate_spc_report(
|
||||
)
|
||||
histogram = compute_histogram(values)
|
||||
|
||||
# 3. Generate SVG charts
|
||||
control_chart_svg = _generate_control_chart_svg(control_chart, subtask_info)
|
||||
histogram_svg = _generate_histogram_svg(histogram, subtask_info)
|
||||
# 3. Generate SVG charts (kaleido shells out and blocks: off the event loop)
|
||||
control_chart_svg = await asyncio.to_thread(
|
||||
_generate_control_chart_svg, control_chart, subtask_info
|
||||
)
|
||||
histogram_svg = await asyncio.to_thread(
|
||||
_generate_histogram_svg, histogram, subtask_info
|
||||
)
|
||||
|
||||
# 4. Get company info and recipe info
|
||||
company = await _get_company_info(db)
|
||||
@@ -313,13 +353,15 @@ async def generate_spc_report(
|
||||
if version_id:
|
||||
filters_desc.append(f"Version: {version_id}")
|
||||
if date_from:
|
||||
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
||||
filters_desc.append(f"From: {_giorno_locale(date_from)}")
|
||||
if date_to:
|
||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
||||
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
||||
if lot_number:
|
||||
filters_desc.append(f"Lot: {lot_number}")
|
||||
if serial_number:
|
||||
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")
|
||||
@@ -332,12 +374,12 @@ async def generate_spc_report(
|
||||
control_chart_svg=control_chart_svg,
|
||||
histogram_svg=histogram_svg,
|
||||
filters_desc=filters_desc,
|
||||
generated_at=datetime.now(),
|
||||
generated_at=to_local(utcnow()),
|
||||
n_measurements=len(measurements),
|
||||
)
|
||||
|
||||
# 7. Convert HTML to PDF
|
||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||||
# 7. Convert HTML to PDF (WeasyPrint blocks: off the event loop)
|
||||
pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
|
||||
return pdf_bytes
|
||||
|
||||
|
||||
@@ -351,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
|
||||
@@ -385,7 +429,7 @@ async def generate_measurement_report(
|
||||
"value": f"{float(m.value):.4f}",
|
||||
"unit": st_info.get("unit", "mm"),
|
||||
"pass_fail": m.pass_fail,
|
||||
"measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
|
||||
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
|
||||
"lot_number": m.lot_number or "—",
|
||||
"serial_number": m.serial_number or "—",
|
||||
@@ -404,13 +448,15 @@ async def generate_measurement_report(
|
||||
if version_id:
|
||||
filters_desc.append(f"Version: {version_id}")
|
||||
if date_from:
|
||||
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
||||
filters_desc.append(f"From: {_giorno_locale(date_from)}")
|
||||
if date_to:
|
||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
||||
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
||||
if lot_number:
|
||||
filters_desc.append(f"Lot: {lot_number}")
|
||||
if serial_number:
|
||||
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")
|
||||
@@ -420,10 +466,10 @@ async def generate_measurement_report(
|
||||
rows=rows,
|
||||
summary=summary,
|
||||
filters_desc=filters_desc,
|
||||
generated_at=datetime.now(),
|
||||
generated_at=to_local(utcnow()),
|
||||
n_measurements=len(measurements),
|
||||
)
|
||||
|
||||
# 8. Convert to PDF
|
||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||||
# 8. Convert to PDF (WeasyPrint blocks: off the event loop)
|
||||
pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
|
||||
return pdf_bytes
|
||||
|
||||
@@ -55,6 +55,27 @@ def compute_summary(
|
||||
)
|
||||
|
||||
|
||||
def _sigma_within(values: list[float]) -> float | None:
|
||||
"""Estimate within-subgroup sigma from the moving range of individuals.
|
||||
|
||||
sigma_within = MRbar / d2, with d2 = 1.128 (moving range of consecutive
|
||||
pairs, i.e. subgroup size 2). `values` must be in chronological order
|
||||
(the order in which measurements arrived) — the moving range is not
|
||||
meaningful on a re-sorted series.
|
||||
|
||||
Returns None when fewer than 2 values are available, or when MRbar == 0
|
||||
(no variation between consecutive points: the index would be undefined).
|
||||
"""
|
||||
n = len(values)
|
||||
if n < 2:
|
||||
return None
|
||||
moving_ranges = [abs(values[i] - values[i - 1]) for i in range(1, n)]
|
||||
mr_bar = stats.mean(moving_ranges)
|
||||
if mr_bar == 0:
|
||||
return None
|
||||
return mr_bar / 1.128
|
||||
|
||||
|
||||
def compute_capability(
|
||||
values: list[float],
|
||||
utl: float | None,
|
||||
@@ -63,12 +84,18 @@ def compute_capability(
|
||||
) -> CapabilityData:
|
||||
"""Compute capability indices Cp, Cpk, Pp, Ppk.
|
||||
|
||||
`values` must be in chronological order (arrival order), since Cp/Cpk are
|
||||
derived from the moving range between consecutive points:
|
||||
|
||||
- sigma_within = MRbar / 1.128 (MRbar = mean of |x_i - x_(i-1)|)
|
||||
- Cp = (UTL - LTL) / (6 * sigma_within)
|
||||
- Cpk = min((UTL - mean) / (3 * sigma), (mean - LTL) / (3 * sigma))
|
||||
- Pp/Ppk: same formulas using population std dev (same data, no subgrouping)
|
||||
- Cpk = min((UTL - mean) / (3 * sigma_within), (mean - LTL) / (3 * sigma_within))
|
||||
- Pp/Ppk: same formulas using the overall sample std dev (n-1, `stats.stdev`)
|
||||
of the whole series — this is the "process performance" pair and is
|
||||
allowed to be wider than Cp/Cpk when there is drift between subgroups.
|
||||
|
||||
Args:
|
||||
values: Measured values.
|
||||
values: Measured values, in chronological order.
|
||||
utl: Upper Tolerance Limit (None if not defined).
|
||||
ltl: Lower Tolerance Limit (None if not defined).
|
||||
nominal: Nominal value (None if not defined).
|
||||
@@ -81,39 +108,39 @@ def compute_capability(
|
||||
return CapabilityData(
|
||||
cp=None, cpk=None, pp=None, ppk=None,
|
||||
mean=values[0] if n == 1 else 0.0,
|
||||
std_dev=0.0, n=n,
|
||||
std_dev=0.0, std_dev_within=None, n=n,
|
||||
utl=utl, ltl=ltl, nominal=nominal,
|
||||
)
|
||||
|
||||
mean = stats.mean(values)
|
||||
# Population std dev for Pp/Ppk
|
||||
std_dev_pop = stats.pstdev(values)
|
||||
# Sample std dev for Cp/Cpk
|
||||
# Overall sample std dev (n-1) for Pp/Ppk
|
||||
std_dev_sample = stats.stdev(values)
|
||||
# Within-subgroup sigma (moving range) for Cp/Cpk
|
||||
sigma_within = _sigma_within(values)
|
||||
|
||||
cp = cpk = pp = ppk = None
|
||||
|
||||
if utl is not None and ltl is not None and std_dev_sample > 0:
|
||||
cp = round((utl - ltl) / (6 * std_dev_sample), 4)
|
||||
if sigma_within is not None:
|
||||
if utl is not None and ltl is not None:
|
||||
cp = round((utl - ltl) / (6 * sigma_within), 4)
|
||||
|
||||
if std_dev_sample > 0:
|
||||
cpk_values = []
|
||||
if utl is not None:
|
||||
cpk_values.append((utl - mean) / (3 * std_dev_sample))
|
||||
cpk_values.append((utl - mean) / (3 * sigma_within))
|
||||
if ltl is not None:
|
||||
cpk_values.append((mean - ltl) / (3 * std_dev_sample))
|
||||
cpk_values.append((mean - ltl) / (3 * sigma_within))
|
||||
if cpk_values:
|
||||
cpk = round(min(cpk_values), 4)
|
||||
|
||||
if utl is not None and ltl is not None and std_dev_pop > 0:
|
||||
pp = round((utl - ltl) / (6 * std_dev_pop), 4)
|
||||
if std_dev_sample > 0:
|
||||
if utl is not None and ltl is not None:
|
||||
pp = round((utl - ltl) / (6 * std_dev_sample), 4)
|
||||
|
||||
if std_dev_pop > 0:
|
||||
ppk_values = []
|
||||
if utl is not None:
|
||||
ppk_values.append((utl - mean) / (3 * std_dev_pop))
|
||||
ppk_values.append((utl - mean) / (3 * std_dev_sample))
|
||||
if ltl is not None:
|
||||
ppk_values.append((mean - ltl) / (3 * std_dev_pop))
|
||||
ppk_values.append((mean - ltl) / (3 * std_dev_sample))
|
||||
if ppk_values:
|
||||
ppk = round(min(ppk_values), 4)
|
||||
|
||||
@@ -121,6 +148,7 @@ def compute_capability(
|
||||
cp=cp, cpk=cpk, pp=pp, ppk=ppk,
|
||||
mean=round(mean, 6),
|
||||
std_dev=round(std_dev_sample, 6),
|
||||
std_dev_within=round(sigma_within, 6) if sigma_within is not None else None,
|
||||
n=n,
|
||||
utl=utl, ltl=ltl, nominal=nominal,
|
||||
)
|
||||
@@ -135,10 +163,12 @@ def compute_control_chart(
|
||||
ltl: float | None,
|
||||
nominal: float | None,
|
||||
) -> ControlChartData:
|
||||
"""Compute control chart data with UCL/LCL and out-of-control detection.
|
||||
"""Compute control chart data (individuals chart) with UCL/LCL and
|
||||
out-of-control detection.
|
||||
|
||||
UCL = mean + 3*sigma
|
||||
LCL = mean - 3*sigma
|
||||
UCL = mean + 3*sigma_within = mean + 2.66*MRbar
|
||||
LCL = mean - 3*sigma_within = mean - 2.66*MRbar
|
||||
with sigma_within = MRbar/1.128 (see `_sigma_within`).
|
||||
Out-of-control: points outside UCL/LCL.
|
||||
|
||||
Args:
|
||||
@@ -160,10 +190,8 @@ def compute_control_chart(
|
||||
|
||||
mean = stats.mean(values)
|
||||
|
||||
if n >= 2:
|
||||
sigma = stats.stdev(values)
|
||||
else:
|
||||
sigma = 0.0
|
||||
sigma_within = _sigma_within(values)
|
||||
sigma = sigma_within if sigma_within is not None else 0.0
|
||||
|
||||
ucl = mean + 3 * sigma
|
||||
lcl = mean - 3 * sigma
|
||||
|
||||
@@ -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,7 +5,10 @@ to the worker over HTTP and stays ignorant of how a number was produced.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -16,40 +19,98 @@ 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||
# Worker responses whose status and reason are forwarded as-is rather than
|
||||
# flattened into a generic 502 - see `_call_worker`.
|
||||
_FORWARDED_WORKER_STATUSES = frozenset({
|
||||
status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
})
|
||||
|
||||
|
||||
def _is_measurable(value: object) -> bool:
|
||||
"""A worker output counts as a quote only if it is a finite, non-bool number.
|
||||
|
||||
None (absent, or explicitly null in a malformed response), a bool (a
|
||||
subclass of int), and NaN/inf all fail this - each one would otherwise
|
||||
reach `float()` and either crash with a bare TypeError or silently save a
|
||||
value nobody could have measured.
|
||||
"""
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(value)
|
||||
)
|
||||
|
||||
|
||||
async def _call_worker(
|
||||
image_bytes: bytes,
|
||||
graph: dict,
|
||||
*,
|
||||
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
|
||||
table: "il worker isola il guasto, l'API resta in piedi, il task
|
||||
riporta l'errore"). A stopped worker raises `httpx.ConnectError`; a job
|
||||
past the timeout raises `httpx.ReadTimeout` - both would otherwise
|
||||
propagate past this function and surface to the operator as a naked
|
||||
500. The response shape is validated here too: a proxy answering 200
|
||||
with something that is not the worker's JSON contract must not turn
|
||||
into a bare KeyError further down.
|
||||
riporta l'errore"). httpx.TimeoutException (ConnectTimeout, ReadTimeout,
|
||||
WriteTimeout, PoolTimeout) becomes 504; every other transport failure
|
||||
(ConnectError, ReadError, RemoteProtocolError when the worker dies
|
||||
mid-response, ...) becomes 502 - both would otherwise propagate past
|
||||
this function and surface to the operator as a naked 500. The response
|
||||
shape is validated here too: a proxy answering 200 with something that
|
||||
is not the worker's JSON contract must not turn into a bare KeyError
|
||||
further down.
|
||||
"""
|
||||
# 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.ConnectError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker is unreachable: {exc}",
|
||||
) from exc
|
||||
except httpx.ReadTimeout as exc:
|
||||
except httpx.TimeoutException as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail=f"the vision worker did not answer in time: {exc}",
|
||||
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} ({base})", # #45
|
||||
) from exc
|
||||
|
||||
# The worker already gave this fault a name - forward its status and
|
||||
# reason instead of flattening every non-200 into a generic 502: 422 is a
|
||||
# bad request (undecodable image, malformed graph), 503 is "busy, try
|
||||
# again" (VISION_MAX_CONCURRENCY exceeded), 504 is its own run timeout
|
||||
# (VISION_RUN_TIMEOUT_S, always tighter than this client's own 120s so
|
||||
# the worker answers first).
|
||||
if response.status_code in _FORWARDED_WORKER_STATUSES:
|
||||
try:
|
||||
detail = response.json().get("detail", response.text)
|
||||
except ValueError:
|
||||
detail = response.text
|
||||
raise HTTPException(status_code=response.status_code, detail=detail)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
@@ -82,6 +143,318 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||
return report
|
||||
|
||||
|
||||
async def call_worker_multipart(
|
||||
path: str,
|
||||
*,
|
||||
files: dict,
|
||||
data: dict,
|
||||
worker_url: str | None = None,
|
||||
timeout: float = 120.0,
|
||||
) -> dict:
|
||||
"""POST multipart generico al worker (#83): /recipes/dxf-compare.
|
||||
|
||||
Stessa traduzione degli errori di `_call_worker`: 504 sul timeout, 502 sul
|
||||
trasporto o su una risposta non JSON-oggetto; 422/503/504 del worker si
|
||||
inoltrano cosi' come sono. Worker della stazione o, se manca, il centrale.
|
||||
"""
|
||||
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(f"{base}{path}", files=files, 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} ({base})",
|
||||
) 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} ({base})",
|
||||
) from exc
|
||||
|
||||
if response.status_code in _FORWARDED_WORKER_STATUSES:
|
||||
try:
|
||||
detail = response.json().get("detail", response.text)
|
||||
except ValueError:
|
||||
detail = response.text
|
||||
raise HTTPException(status_code=response.status_code, detail=detail)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
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=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
||||
) from exc
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: expected a JSON object",
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
async def dxf_compare_recipe(
|
||||
image_bytes: bytes, dxf_bytes: bytes, *, px_per_mm: float, band_mm: float,
|
||||
min_points_pct: float, polarity: str, worker_url: str | None = None,
|
||||
) -> dict:
|
||||
"""W2: ricetta dxf-compare da immagine + DXF (#83). Sostituita dai test."""
|
||||
body = await call_worker_multipart(
|
||||
"/recipes/dxf-compare",
|
||||
files={
|
||||
"image": ("frame.png", image_bytes, "image/png"),
|
||||
"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream"),
|
||||
},
|
||||
data={
|
||||
"px_per_mm": str(px_per_mm), "band_mm": str(band_mm),
|
||||
"min_points_pct": str(min_points_pct), "polarity": polarity,
|
||||
},
|
||||
worker_url=worker_url,
|
||||
)
|
||||
# forma minima del contratto W2: senza graph e files non c'e' nulla da salvare
|
||||
if not isinstance(body.get("graph"), dict) or not isinstance(body.get("files"), dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: missing graph or files",
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
# Stati di fetch_station_calibration (#83)
|
||||
CAL_OK = "ok"
|
||||
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
||||
CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore inutilizzabile)
|
||||
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
|
||||
|
||||
|
||||
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
||||
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
|
||||
|
||||
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
|
||||
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
|
||||
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
|
||||
Un px_per_mm non finito o <= 0 conta come non calibrata.
|
||||
"""
|
||||
if station is None or not station.camera_url or not station.camera_source:
|
||||
return None, CAL_NO_CAMERA
|
||||
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
|
||||
try:
|
||||
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(url, params={"source": station.camera_source})
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
|
||||
return None, CAL_UNREACHABLE
|
||||
if response.status_code == status.HTTP_404_NOT_FOUND:
|
||||
return None, CAL_NOT_CALIBRATED
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
|
||||
)
|
||||
return None, CAL_UNREACHABLE
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
|
||||
return None, CAL_UNREACHABLE
|
||||
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
|
||||
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
|
||||
return None, CAL_NOT_CALIBRATED
|
||||
return body, CAL_OK
|
||||
|
||||
|
||||
async def get_station_calibration(station: Station | None) -> dict | None:
|
||||
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
|
||||
calibration, _ = await fetch_station_calibration(station)
|
||||
return calibration
|
||||
|
||||
|
||||
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,
|
||||
@@ -92,7 +465,48 @@ async def execute_task(
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None,
|
||||
) -> tuple[VisionResult, list[Measurement]]:
|
||||
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",
|
||||
)
|
||||
|
||||
version = (await db.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.id == version_id)
|
||||
)).scalar_one_or_none()
|
||||
if version is None or not version.is_current:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="this task's recipe version is no longer current",
|
||||
)
|
||||
|
||||
recipe = await measurement_service.recipe_of_version(db, version_id)
|
||||
if recipe is None or not recipe.active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="this recipe is not active",
|
||||
)
|
||||
|
||||
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.
|
||||
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
|
||||
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
|
||||
calibration = 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
|
||||
calibration = await get_station_calibration(station)
|
||||
|
||||
if not task.vision_json:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
@@ -141,12 +555,26 @@ async def execute_task(
|
||||
),
|
||||
)
|
||||
|
||||
report = await _call_worker(image_bytes, task.vision_json)
|
||||
# La calibrazione viene dall'acquisitore della stazione (#83); se non c'e',
|
||||
# non si passa `calibration` e la ricetta porta la sua (#46).
|
||||
worker_kwargs: dict = {}
|
||||
if calibration is not None:
|
||||
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
|
||||
report = await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
worker_url=station_url, # #45
|
||||
**worker_kwargs,
|
||||
)
|
||||
outputs = report["outputs"]
|
||||
|
||||
# Every quote or none. A half-measured piece read as a whole one is worse
|
||||
# than a piece not measured at all.
|
||||
missing = [q.vision_output for q in quotes if q.vision_output not in outputs]
|
||||
# than a piece not measured at all. A quote counts as produced only if its
|
||||
# output is present *and* a finite, non-bool number: the in-process runner
|
||||
# already drops NaN/inf itself (see src/vision/runner.py), but the worker
|
||||
# is reached over HTTP - a different implementation of it, or a malformed
|
||||
# response, could still hand back None or a non-finite float for a mapped
|
||||
# key instead of omitting it outright.
|
||||
missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))]
|
||||
if missing:
|
||||
failed = ", ".join(f["tool_name"] for f in report["failures"])
|
||||
raise HTTPException(
|
||||
@@ -157,11 +585,20 @@ 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,
|
||||
# copiata come il grafo (#83): ricalibrare dopo non cambia cio' che e' stato misurato
|
||||
calibration_snapshot=calibration,
|
||||
duration_ms=report.get("duration_ms"),
|
||||
)
|
||||
db.add(result)
|
||||
@@ -183,7 +620,38 @@ async def execute_task(
|
||||
enforce_tolerance_gate=False,
|
||||
))
|
||||
|
||||
return result, saved
|
||||
return result, saved, report
|
||||
|
||||
|
||||
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
||||
return settings.upload_path / reference.path
|
||||
|
||||
|
||||
def remove_reference_file(reference: VisionReferenceImage) -> None:
|
||||
"""Delete a reference image's file, if it is still there.
|
||||
|
||||
Called by the router only once the row's fate is durable: after a
|
||||
successful commit when the row was just deleted, or to undo a
|
||||
`save_reference_image` whose row failed to commit (see both call sites).
|
||||
`missing_ok` because "already gone" must never be an error here.
|
||||
"""
|
||||
_reference_file_path(reference).unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def file_still_referenced(db: AsyncSession, path: str) -> bool:
|
||||
"""True if some VisionReferenceImage row still points at *path*.
|
||||
|
||||
Copy-on-write (#38.6) can leave several rows sharing the same file on
|
||||
disk (the copy carries over the source's `path` rather than duplicating
|
||||
the file). The router must call this, after the row being deleted has
|
||||
been committed, before it calls `remove_reference_file` - otherwise
|
||||
deleting one task's reference would take the file out from under every
|
||||
other task that still references it.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(VisionReferenceImage.id).where(VisionReferenceImage.path == path).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def save_reference_image(
|
||||
@@ -202,17 +670,22 @@ async def save_reference_image(
|
||||
`extension` must match the upload's real content type (the router derives
|
||||
it from `image.content_type`) - writing a jpeg to disk as `<uuid>.png`
|
||||
would be a lie the filename tells forever.
|
||||
|
||||
The file is written only after the row has flushed successfully - a bad
|
||||
FK or constraint fails at `flush()`, before anything exists on disk, so
|
||||
there is nothing to clean up. The caller still has to remove the file if
|
||||
the *commit* that follows fails (`remove_reference_file`, from the
|
||||
router): a flush is not durable, and an orphaned file from a rolled-back
|
||||
row is exactly what #38 found.
|
||||
"""
|
||||
# `upload_path` is the resolved Path, `upload_dir` the bare setting: the
|
||||
# rest of the codebase uses the former (see production_export_service).
|
||||
folder = settings.upload_path / "vision" / "reference" / str(task_id)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{uuid4().hex}.{extension}"
|
||||
(folder / name).write_bytes(image_bytes)
|
||||
relative_path = str(Path("vision") / "reference" / str(task_id) / name)
|
||||
|
||||
reference = VisionReferenceImage(
|
||||
task_id=task_id,
|
||||
path=str(Path("vision") / "reference" / str(task_id) / name),
|
||||
path=relative_path,
|
||||
note=note,
|
||||
station_id=station_id,
|
||||
device_code=device_code,
|
||||
@@ -220,10 +693,46 @@ async def save_reference_image(
|
||||
)
|
||||
db.add(reference)
|
||||
await db.flush()
|
||||
|
||||
folder = _reference_file_path(reference).parent
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
_reference_file_path(reference).write_bytes(image_bytes)
|
||||
|
||||
await db.refresh(reference)
|
||||
return reference
|
||||
|
||||
|
||||
async def list_reference_images(
|
||||
db: AsyncSession, task_id: int,
|
||||
) -> list[VisionReferenceImage]:
|
||||
result = await db.execute(
|
||||
select(VisionReferenceImage)
|
||||
.where(VisionReferenceImage.task_id == task_id)
|
||||
.order_by(VisionReferenceImage.acquired_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def delete_reference_image(
|
||||
db: AsyncSession, reference_id: int,
|
||||
) -> VisionReferenceImage:
|
||||
"""Flush the row's deletion. The caller (router) removes the file only
|
||||
once the commit that makes the deletion durable has actually succeeded -
|
||||
deleting it any earlier would leave the row pointing at a file that is
|
||||
already gone if the commit then failed.
|
||||
"""
|
||||
reference = (await db.execute(
|
||||
select(VisionReferenceImage).where(VisionReferenceImage.id == reference_id)
|
||||
)).scalar_one_or_none()
|
||||
if reference is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Reference image not found",
|
||||
)
|
||||
await db.delete(reference)
|
||||
await db.flush()
|
||||
return reference
|
||||
|
||||
|
||||
async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dict:
|
||||
"""Run the graph and save nothing. Composing is not measuring."""
|
||||
if not task.vision_json:
|
||||
@@ -231,4 +740,45 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_points_quote(
|
||||
db: AsyncSession, task: RecipeTask, min_points_pct: float,
|
||||
) -> bool:
|
||||
"""Crea la quota «Punti in tolleranza» se il task non ha nessuna quota di visione (#83).
|
||||
|
||||
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
|
||||
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
|
||||
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
|
||||
"""
|
||||
from src.backend.services import recipe_service # import locale: evita cicli
|
||||
|
||||
existing = (await db.execute(
|
||||
select(RecipeSubtask.id)
|
||||
.where(RecipeSubtask.task_id == task.id)
|
||||
.where(RecipeSubtask.vision_output.is_not(None))
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return False
|
||||
if await recipe_service.version_has_measurements(db, task.version_id):
|
||||
return False
|
||||
markers = (await db.execute(
|
||||
select(RecipeSubtask.marker_number).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().all()
|
||||
db.add(RecipeSubtask(
|
||||
task_id=task.id,
|
||||
marker_number=max(markers, default=0) + 1, # unico per task (uq_task_marker)
|
||||
description="Punti in tolleranza",
|
||||
nominal=100,
|
||||
# limiti assoluti come nel resto delle quote: passa se punti% >= min_points_pct
|
||||
ltl=min_points_pct,
|
||||
utl=100,
|
||||
unit="%",
|
||||
vision_output="dxf.points_in_pct",
|
||||
))
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
@@ -40,7 +40,7 @@ from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.services.auth_service import hash_password, generate_api_key
|
||||
from src.backend.services.auth_service import hash_password, issue_api_key
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory SQLite engine for tests
|
||||
@@ -142,7 +142,6 @@ async def _create_user(
|
||||
active: bool = True,
|
||||
) -> User:
|
||||
"""Insert a user into the test DB and return it with an API key set."""
|
||||
api_key = generate_api_key()
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
@@ -150,13 +149,14 @@ async def _create_user(
|
||||
roles=roles or [],
|
||||
is_admin=is_admin,
|
||||
active=active,
|
||||
api_key=api_key,
|
||||
language_pref="en",
|
||||
theme_pref="light",
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
# Clear key of this test session; the DB only keeps its hash.
|
||||
user.plain_key = await issue_api_key(session, user)
|
||||
return user
|
||||
|
||||
|
||||
@@ -278,4 +278,4 @@ async def create_test_recipe(
|
||||
|
||||
def auth_headers(user: User) -> dict[str, str]:
|
||||
"""Return headers dict with the user's API key for authenticated requests."""
|
||||
return {"X-API-Key": user.api_key}
|
||||
return {"X-API-Key": user.plain_key}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Unit tests for AccessLogMiddleware (gitea#36 point 6).
|
||||
|
||||
- action must be truncated to fit AccessLog.action (String(100)): an
|
||||
untruncated long path used to fail the INSERT silently (swallowed by the
|
||||
broad except in dispatch()).
|
||||
- ip_address must come from the same `_client_ip` the rate limiter uses
|
||||
(proxy-aware), not the raw `request.client.host`, which behind
|
||||
Nginx/Flask is always localhost.
|
||||
|
||||
These stub out async_session_factory and inspect the compiled INSERT
|
||||
statement directly, rather than going through a real DB round trip: Core
|
||||
`insert()` against the test's in-memory SQLite has an unrelated
|
||||
autoincrement quirk (unrelated to this fix) that makes end-to-end
|
||||
assertions on the persisted row unreliable here.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Records the statement passed to execute(); no real DB involved."""
|
||||
|
||||
def __init__(self):
|
||||
self.executed_stmt = None
|
||||
|
||||
async def execute(self, stmt):
|
||||
self.executed_stmt = stmt
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def _make_request(path: str, extra_headers: list[tuple[bytes, bytes]] | None = None) -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": path,
|
||||
"query_string": b"",
|
||||
"headers": extra_headers or [],
|
||||
"client": ("127.0.0.1", 12345),
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
|
||||
async def test_long_path_action_is_truncated():
|
||||
fake_session = _FakeSession()
|
||||
long_path = "/api/files/" + "a" * 200
|
||||
|
||||
with patch(
|
||||
"src.backend.api.middleware.logging.async_session_factory",
|
||||
return_value=fake_session,
|
||||
):
|
||||
middleware = AccessLogMiddleware(app=None)
|
||||
request = _make_request(long_path)
|
||||
|
||||
async def call_next(_req):
|
||||
return Response(status_code=404)
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert fake_session.executed_stmt is not None
|
||||
params = fake_session.executed_stmt.compile().params
|
||||
assert len(params["action"]) <= 100
|
||||
assert params["action"] == f"GET {long_path}"[:100]
|
||||
|
||||
|
||||
async def test_short_path_action_is_unaffected():
|
||||
fake_session = _FakeSession()
|
||||
|
||||
with patch(
|
||||
"src.backend.api.middleware.logging.async_session_factory",
|
||||
return_value=fake_session,
|
||||
):
|
||||
middleware = AccessLogMiddleware(app=None)
|
||||
request = _make_request("/api/settings/")
|
||||
|
||||
async def call_next(_req):
|
||||
return Response(status_code=200)
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
params = fake_session.executed_stmt.compile().params
|
||||
assert params["action"] == "GET /api/settings/"
|
||||
|
||||
|
||||
async def test_ip_address_uses_client_ip_resolution():
|
||||
"""trusted_proxy_count defaults to 1: the last XFF hop (our own proxy's
|
||||
add) is trusted, not request.client.host (always the proxy itself)."""
|
||||
fake_session = _FakeSession()
|
||||
|
||||
with patch(
|
||||
"src.backend.api.middleware.logging.async_session_factory",
|
||||
return_value=fake_session,
|
||||
):
|
||||
middleware = AccessLogMiddleware(app=None)
|
||||
request = _make_request(
|
||||
"/api/settings/",
|
||||
extra_headers=[(b"x-forwarded-for", b"203.0.113.7, 10.0.0.1")],
|
||||
)
|
||||
|
||||
async def call_next(_req):
|
||||
return Response(status_code=200)
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
params = fake_session.executed_stmt.compile().params
|
||||
assert params["ip_address"] == "10.0.0.1"
|
||||
assert params["ip_address"] != "127.0.0.1" # not request.client.host
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tests for ai_service.py error handling (gitea#36 point 7).
|
||||
|
||||
A malformed response from the AI model (missing fields, invalid JSON) must
|
||||
raise the dedicated AIResponseError, not a bare KeyError/JSONDecodeError —
|
||||
JSONDecodeError in particular is itself a ValueError subclass, so without
|
||||
this it was caught by the router's `except ValueError` (meant for our own
|
||||
config errors) and misreported as a 400 instead of a 502.
|
||||
"""
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.backend.services import ai_service
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, json_data):
|
||||
self._json_data = json_data
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return self._json_data
|
||||
|
||||
|
||||
def _patch_openrouter(monkeypatch, response_json: dict):
|
||||
monkeypatch.setattr(ai_service, "extract_text_from_pdf", lambda _b: "some extracted text")
|
||||
monkeypatch.setattr(ai_service.settings, "openrouter_api_key", "test-key")
|
||||
monkeypatch.setattr(ai_service.settings, "openrouter_model", "test-model")
|
||||
|
||||
fake_client = AsyncMock()
|
||||
fake_client.post = AsyncMock(return_value=_FakeResponse(response_json))
|
||||
fake_client.__aenter__ = AsyncMock(return_value=fake_client)
|
||||
fake_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
return patch("src.backend.services.ai_service.httpx.AsyncClient", return_value=fake_client)
|
||||
|
||||
|
||||
async def test_missing_choices_key_raises_ai_response_error(monkeypatch):
|
||||
with _patch_openrouter(monkeypatch, {"no_choices_here": True}):
|
||||
with pytest.raises(ai_service.AIResponseError):
|
||||
await ai_service.parse_technical_sheet(b"%PDF-fake")
|
||||
|
||||
|
||||
async def test_invalid_json_content_raises_ai_response_error(monkeypatch):
|
||||
bad_content = "not valid json {{{"
|
||||
response_json = {"choices": [{"message": {"content": bad_content}}]}
|
||||
with _patch_openrouter(monkeypatch, response_json):
|
||||
with pytest.raises(ai_service.AIResponseError):
|
||||
await ai_service.parse_technical_sheet(b"%PDF-fake")
|
||||
|
||||
|
||||
async def test_valid_json_content_returns_tasks(monkeypatch):
|
||||
tasks = [{"title": "MATERIALI", "directive": "Verificare", "description": "..."}]
|
||||
response_json = {"choices": [{"message": {"content": json.dumps(tasks)}}]}
|
||||
with _patch_openrouter(monkeypatch, response_json):
|
||||
result = await ai_service.parse_technical_sheet(b"%PDF-fake")
|
||||
assert result == tasks
|
||||
|
||||
|
||||
async def test_empty_extracted_text_returns_empty_list(monkeypatch):
|
||||
"""No AI call at all when the PDF has no extractable text."""
|
||||
monkeypatch.setattr(ai_service, "extract_text_from_pdf", lambda _b: " ")
|
||||
result = await ai_service.parse_technical_sheet(b"%PDF-fake")
|
||||
assert result == []
|
||||
@@ -78,6 +78,136 @@ class TestLogin:
|
||||
assert len(api_key) >= 32
|
||||
|
||||
|
||||
class TestLoginKeyPerSession:
|
||||
"""Each login issues its own key; none revokes another (#25, #41)."""
|
||||
|
||||
async def test_two_logins_same_user_get_distinct_valid_keys(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
await _create_user(db_session, username="twostations", password="TwoStations1")
|
||||
creds = {"username": "twostations", "password": "TwoStations1"}
|
||||
resp1 = await client.post("/api/auth/login", json=creds)
|
||||
resp2 = await client.post("/api/auth/login", json=creds)
|
||||
k1, k2 = resp1.json()["api_key"], resp2.json()["api_key"]
|
||||
assert k1 != k2
|
||||
for k in (k1, k2):
|
||||
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_db_stores_hash_never_clear_key(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
from sqlalchemy import select
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.services.auth_service import hash_api_key
|
||||
|
||||
await _create_user(db_session, username="hashed", password="HashedPass1")
|
||||
resp = await client.post(
|
||||
"/api/auth/login", json={"username": "hashed", "password": "HashedPass1"}
|
||||
)
|
||||
key = resp.json()["api_key"]
|
||||
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
|
||||
assert hash_api_key(key) in hashes
|
||||
assert key not in hashes
|
||||
assert all(len(h) == 64 for h in hashes)
|
||||
|
||||
async def test_expired_key_gives_401(self, client: AsyncClient, db_session):
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import update
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.services.auth_service import utcnow
|
||||
|
||||
user = await _create_user(db_session, username="expired", password="ExpiredPass1")
|
||||
await db_session.execute(
|
||||
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
|
||||
)
|
||||
resp = await client.get("/api/auth/me", headers=auth_headers(user))
|
||||
assert resp.status_code == 401
|
||||
assert "expired" in resp.json()["detail"].lower()
|
||||
|
||||
async def test_use_renews_expiry_only_after_interval(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import select, update
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.services.auth_service import utcnow
|
||||
|
||||
user = await _create_user(db_session, username="renew", password="RenewPass1")
|
||||
headers = auth_headers(user)
|
||||
old = utcnow() - timedelta(hours=10)
|
||||
|
||||
# Recent use: no write, expiry untouched.
|
||||
recent = utcnow() - timedelta(minutes=1)
|
||||
await db_session.execute(
|
||||
update(ApiKey).values(expires_at=old + timedelta(hours=24), last_used_at=recent)
|
||||
)
|
||||
await client.get("/api/auth/me", headers=headers)
|
||||
row = (await db_session.execute(select(ApiKey))).scalar_one()
|
||||
await db_session.refresh(row)
|
||||
assert row.last_used_at == recent
|
||||
|
||||
# Stale last use: expiry is pushed forward.
|
||||
await db_session.execute(
|
||||
update(ApiKey).values(last_used_at=utcnow() - timedelta(minutes=10))
|
||||
)
|
||||
before = utcnow()
|
||||
resp = await client.get("/api/auth/me", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
await db_session.refresh(row)
|
||||
assert row.expires_at >= before + timedelta(hours=23, minutes=59)
|
||||
|
||||
|
||||
class TestLogout:
|
||||
"""Logout deletes only this session's key (#41)."""
|
||||
|
||||
async def test_logout_removes_only_own_key(self, client: AsyncClient, db_session):
|
||||
await _create_user(db_session, username="shared_logout", password="SharedPass1")
|
||||
creds = {"username": "shared_logout", "password": "SharedPass1"}
|
||||
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
resp = await client.post("/api/auth/logout", headers={"X-API-Key": k1})
|
||||
assert resp.status_code == 204
|
||||
|
||||
assert (await client.get("/api/auth/me", headers={"X-API-Key": k1})).status_code == 401
|
||||
assert (await client.get("/api/auth/me", headers={"X-API-Key": k2})).status_code == 200
|
||||
|
||||
async def test_logout_all_sessions_removes_every_key(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
await _create_user(db_session, username="shared_logout_all", password="SharedPass1")
|
||||
creds = {"username": "shared_logout_all", "password": "SharedPass1"}
|
||||
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
resp = await client.post(
|
||||
"/api/auth/logout", params={"all_sessions": "true"},
|
||||
headers={"X-API-Key": k1},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
for k in (k1, k2):
|
||||
assert (await client.get("/api/auth/me", headers={"X-API-Key": k})).status_code == 401
|
||||
|
||||
|
||||
class TestAuthenticateUserTiming:
|
||||
"""authenticate_user pays the bcrypt cost even for an unknown username (#25)."""
|
||||
|
||||
async def test_unknown_username_still_returns_none(self, db_session):
|
||||
from src.backend.services.auth_service import authenticate_user
|
||||
|
||||
result = await authenticate_user(db_session, "no-such-user-at-all", "whatever")
|
||||
assert result is None
|
||||
|
||||
def test_dummy_hash_is_a_valid_bcrypt_hash(self):
|
||||
from src.backend.services.auth_service import (
|
||||
_DUMMY_PASSWORD_HASH,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
assert verify_password(
|
||||
"tiemeasureflow-dummy-hash-for-timing", _DUMMY_PASSWORD_HASH
|
||||
)
|
||||
|
||||
|
||||
class TestHealthAndAuth:
|
||||
"""Health check and auth requirement tests."""
|
||||
|
||||
@@ -94,3 +224,69 @@ class TestHealthAndAuth:
|
||||
resp = await client.get("/api/users")
|
||||
assert resp.status_code == 401
|
||||
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
|
||||
|
||||
|
||||
class TestRevokeAllKeys:
|
||||
"""Password change, deactivation and regenerate invalidate every key (#41)."""
|
||||
|
||||
async def _two_keys(self, client, db_session, name):
|
||||
user = await _create_user(db_session, username=name, password="KeysPass123")
|
||||
creds = {"username": name, "password": "KeysPass123"}
|
||||
keys = [
|
||||
(await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
for _ in range(2)
|
||||
]
|
||||
return user, keys
|
||||
|
||||
async def _all_401(self, client, keys):
|
||||
for k in keys:
|
||||
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_password_change_invalidates_all(self, client, db_session, admin_user):
|
||||
user, keys = await self._two_keys(client, db_session, "pwkeys")
|
||||
resp = await client.put(
|
||||
f"/api/users/{user.id}/password",
|
||||
headers=auth_headers(admin_user), json={"password": "NewSecurePass1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
await self._all_401(client, keys)
|
||||
|
||||
async def test_deactivation_invalidates_all(self, client, db_session, admin_user):
|
||||
user, keys = await self._two_keys(client, db_session, "offkeys")
|
||||
resp = await client.delete(
|
||||
f"/api/users/{user.id}", headers=auth_headers(admin_user)
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
await self._all_401(client, keys)
|
||||
|
||||
async def test_regenerate_invalidates_old_keys(self, client, db_session, admin_user):
|
||||
user, keys = await self._two_keys(client, db_session, "regenkeys")
|
||||
resp = await client.post(
|
||||
f"/api/users/{user.id}/regenerate-key", headers=auth_headers(admin_user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
await self._all_401(client, keys)
|
||||
new = resp.json()["api_key"]
|
||||
assert (await client.get("/api/auth/me", headers={"X-API-Key": new})).status_code == 200
|
||||
|
||||
|
||||
class TestExpiredKeyCleanup:
|
||||
async def test_expired_key_removed_at_next_login(self, client, db_session):
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import select, update
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.services.auth_service import hash_api_key, utcnow
|
||||
|
||||
user = await _create_user(db_session, username="cleanup", password="CleanPass123")
|
||||
old_hash = hash_api_key(user.plain_key)
|
||||
await db_session.execute(
|
||||
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login", json={"username": "cleanup", "password": "CleanPass123"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
|
||||
assert old_hash not in hashes
|
||||
assert len(hashes) == 1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user