Compare commits
331 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dd4b0a20c | |||
| 271ce73d09 | |||
| 1bd6c6b011 | |||
| d8bca535ac | |||
| 14257304f8 | |||
| 5738920c9c | |||
| 452862e0f3 | |||
| 999f47092d | |||
| 6441a92062 | |||
| 8c361c14ae | |||
| 5897a9e5df | |||
| f44b578d5b | |||
| c5be9a2bb4 | |||
| 17ea010f7e | |||
| 1d6d74b2e4 | |||
| 2f0a5aaf1e | |||
| e96fbb73c2 | |||
| f3bacc8238 | |||
| e743363a62 | |||
| d48a8a1076 | |||
| 8ef61a1285 | |||
| 84fc1a08f7 | |||
| c904800e94 | |||
| 20c0a47c00 | |||
| 269bbc03ca | |||
| 815d49d699 | |||
| 43ef22079d | |||
| b01bba536a | |||
| 17344aae13 | |||
| fd3384f08c | |||
| 08aefa9888 | |||
| 06f308be6f | |||
| 93afd6187c | |||
| 8217f46d47 | |||
| d4802fd1a3 | |||
| ea64f3c524 | |||
| 58baed19e2 | |||
| 25b0741003 | |||
| df67a32a82 | |||
| 5723f055c4 | |||
| 4485803122 | |||
| acd94a9d33 | |||
| 88b3f202b1 | |||
| 4a11d2595f | |||
| b46ccba1ee | |||
| 7a1df0bca4 | |||
| 198c6c63c4 | |||
| 04b81ad2f2 | |||
| 45c9e6c3f3 | |||
| ca6cd0cab1 | |||
| 34c251aa90 | |||
| c70d29d6d7 | |||
| 96aca975d0 | |||
| b3c56ef5b5 | |||
| 9111eb2741 | |||
| b46a70347f | |||
| 155abfd15c | |||
| 2c2d601495 | |||
| b2e3867370 | |||
| cab419bdc7 | |||
| 249999fa1e | |||
| a3e032d000 | |||
| 57376556d2 | |||
| 7cb877b11b | |||
| 08616aa1fa | |||
| 02f1481fe5 | |||
| 34e1b042f5 | |||
| bdef1f1cd8 | |||
| 39b9ad77eb | |||
| 12632323e8 | |||
| 839a61e479 | |||
| 9b8e729927 | |||
| 6080e6aebc | |||
| d5db5f3014 | |||
| 1a19bb6679 | |||
| 20f33234a8 | |||
| 711abc96df | |||
| 77419f9ebd | |||
| 4791c34205 | |||
| e582df7962 | |||
| a7b91b5508 | |||
| addf5a2598 | |||
| bdb1e8c8b9 | |||
| ebaafbe6e9 | |||
| 351153e78a | |||
| 842ceca007 | |||
| b78f2422d8 | |||
| 444dc0a6d1 | |||
| 27d7c1e832 | |||
| 4b1b23cfb6 | |||
| 4b57efc73c | |||
| 7c03f53000 | |||
| 67337fa232 | |||
| 661b782bad | |||
| 25f8b4d609 | |||
| b98d1f15f4 | |||
| 8f9731839a | |||
| 552f96bb47 | |||
| 71daab184f | |||
| 9beaf2ed67 | |||
| 800ec41254 | |||
| 9c45a51770 | |||
| 4a48af2ae3 | |||
| b879563738 | |||
| f504c86491 | |||
| 632fd51050 | |||
| 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" }
|
||||
+12
-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,9 @@ 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
|
||||
|
||||
# #109: scritto da packaging/windows/sviluppo.ps1 (sha del submodule), non va versionato
|
||||
/vision-engine-version.txt
|
||||
|
||||
+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: 9eb6f4c.
|
||||
|
||||
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,601 @@
|
||||
"""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 camere_da_scaldare(db: Path) -> list[tuple[str, str]]:
|
||||
"""Coppie (camera_url, camera_source) configurate nel db (stazioni e task di ricetta), senza doppioni.
|
||||
Solo sorgenti "camera:" che aprono hardware (non "camera:folder"). Errore sqlite -> lista vuota (#108)."""
|
||||
coppie: list[tuple[str, str]] = []
|
||||
|
||||
def aggiungi(url, src) -> None:
|
||||
# #108: valida la coppia e scarta duplicati e sorgenti che non aprono hardware
|
||||
if not (isinstance(url, str) and isinstance(src, str) and url.strip() and src.strip()):
|
||||
return
|
||||
if not src.startswith("camera:") or src == "camera:folder":
|
||||
return
|
||||
if (url, src) not in coppie:
|
||||
coppie.append((url, src))
|
||||
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
|
||||
try:
|
||||
for url, src in con.execute("SELECT camera_url, camera_source FROM stations"):
|
||||
aggiungi(url, src)
|
||||
for (blob,) in con.execute("SELECT acquisition_json FROM recipe_tasks WHERE acquisition_json IS NOT NULL"):
|
||||
try:
|
||||
cfg = json.loads(blob)
|
||||
except (TypeError, ValueError):
|
||||
continue # #108: JSON non valido, si salta
|
||||
if isinstance(cfg, dict):
|
||||
aggiungi(cfg.get("camera_url"), cfg.get("camera_source"))
|
||||
finally:
|
||||
con.close()
|
||||
except Exception:
|
||||
return []
|
||||
return coppie
|
||||
|
||||
|
||||
def scalda_camere(db: Path) -> None:
|
||||
"""Apre ogni camera configurata con un grab a vuoto, cosi' il primo "Acquisisci" non aspetta ~25 s (#108).
|
||||
Gira in un thread daemon: nessuna eccezione esce."""
|
||||
try:
|
||||
camere = camere_da_scaldare(db)
|
||||
if not camere:
|
||||
return
|
||||
for _ in range(300): # aspetta il servizio camera (max ~60 s), come _apri_browser_quando_pronto
|
||||
if _porta_aperta(PORTA_CAMERA):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
log.warning("scalda camere: servizio camera non raggiungibile, salto")
|
||||
return
|
||||
for url, src in camere:
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
richiesta = urllib.request.Request(
|
||||
f"{url.rstrip('/')}/camera/grab",
|
||||
data=json.dumps({"source": src, "trigger": "continuo", "timeout_ms": 5000}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(richiesta, timeout=90) as r:
|
||||
esito = f"HTTP {r.status}"
|
||||
log.info("scalda camera %s: %s in %.1f s", src, esito, time.monotonic() - t0)
|
||||
except Exception as e: # #108: una camera che non risponde non ferma le altre
|
||||
log.warning("scalda camera %s: %s dopo %.1f s", src, e, time.monotonic() - t0)
|
||||
except Exception:
|
||||
log.exception("scalda camere non riuscito")
|
||||
|
||||
|
||||
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)
|
||||
# #108: apre le camere configurate con un grab a vuoto, con o senza browser
|
||||
threading.Thread(target=scalda_camere, args=(dati / "tmf.db",), daemon=True).start()
|
||||
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,72 @@
|
||||
<#
|
||||
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). Scrive vision-engine-version.txt nella radice (sha del submodule
|
||||
vendor/visionsuite) se git c'e'; senza git e senza il file si ferma con un errore chiaro (#109).
|
||||
|
||||
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), git (opzionale: senza, serve vision-engine-version.txt gia' nella radice), 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
|
||||
|
||||
# #109: il worker di visione rifiuta ogni job senza VISION_ENGINE_VERSION, che avvio.py legge da
|
||||
# <radice>\vision-engine-version.txt (come nel pacchetto, build.ps1). Da sorgente lo scrive questo script.
|
||||
$versione = Join-Path $radice 'vision-engine-version.txt'
|
||||
$shaMotore = $null
|
||||
# #109: solo se il submodule e' inizializzato (ha .git): altrimenti git risale al repo padre e darebbe lo sha di TMF
|
||||
if ((Get-Command git -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $radice 'vendor\visionsuite\.git'))) {
|
||||
$shaMotore = (git -C (Join-Path $radice 'vendor\visionsuite') rev-parse HEAD 2>$null)
|
||||
if ($LASTEXITCODE -ne 0) { $shaMotore = $null }
|
||||
}
|
||||
if ($shaMotore) {
|
||||
# 40 caratteri ASCII, UTF8 senza BOM e senza a capo (avvio.py fa .strip(), che non toglie il BOM)
|
||||
[System.IO.File]::WriteAllText($versione, $shaMotore.Trim(), (New-Object System.Text.UTF8Encoding $false))
|
||||
} elseif (Test-Path $versione) {
|
||||
Write-Host "git non disponibile: uso $versione = $((Get-Content -Raw $versione).Trim())"
|
||||
} else {
|
||||
throw "Manca vision-engine-version.txt e git non puo' leggere vendor\visionsuite. Rimedio: crea l'archivio con 'uv run python scripts/archivio_sorgente.py --out tmf.zip' da un clone con git, oppure scrivi in $versione lo sha del submodule (40 caratteri, ASCII, senza BOM e senza a capo)."
|
||||
}
|
||||
|
||||
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,357 @@
|
||||
"""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_versione_motore_visione_letta_dalla_radice_sorgente(avvio, tmp_path, monkeypatch):
|
||||
# #109: da sorgente il file vision-engine-version.txt sta nella radice del repo e arriva al worker
|
||||
prog = tmp_path / "prog"
|
||||
prog.mkdir()
|
||||
(prog / "vision-engine-version.txt").write_bytes(b"a" * 40)
|
||||
monkeypatch.setattr(avvio, "cartella_programma", lambda: prog)
|
||||
avvio.imposta_ambiente(tmp_path / "dati", "127.0.0.1", {k: "x" * 32 for k in avvio.SEGRETI})
|
||||
assert os.environ["VISION_ENGINE_VERSION"] == "a" * 40
|
||||
|
||||
|
||||
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)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- scalda camere (#108)
|
||||
|
||||
def _db_camere(percorso):
|
||||
# #108: db minimo con le due tabelle lette da camere_da_scaldare
|
||||
con = sqlite3.connect(percorso)
|
||||
con.execute("CREATE TABLE stations (id INTEGER PRIMARY KEY, camera_url TEXT, camera_source TEXT)")
|
||||
con.execute("CREATE TABLE recipe_tasks (id INTEGER PRIMARY KEY, acquisition_json TEXT)")
|
||||
con.executemany("INSERT INTO stations (camera_url, camera_source) VALUES (?, ?)", [
|
||||
("http://127.0.0.1:8765", "camera:601020"), # valida
|
||||
(None, None), # stazione senza camera
|
||||
("http://127.0.0.1:8765", "camera:folder"), # non apre hardware
|
||||
])
|
||||
con.executemany("INSERT INTO recipe_tasks (acquisition_json) VALUES (?)", [
|
||||
(json.dumps({"camera_url": "http://127.0.0.1:8765", "camera_source": "camera:601020"}),), # duplicato
|
||||
(json.dumps({"camera_url": "http://10.0.0.5:8765/", "camera_source": "camera:777"}),), # valida
|
||||
("{rotto",), # JSON non valido
|
||||
(None,),
|
||||
])
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def test_camere_da_scaldare_filtra_e_deduplica(avvio, tmp_path):
|
||||
db = tmp_path / "tmf.db"
|
||||
_db_camere(db)
|
||||
assert avvio.camere_da_scaldare(db) == [
|
||||
("http://127.0.0.1:8765", "camera:601020"),
|
||||
("http://10.0.0.5:8765/", "camera:777"),
|
||||
]
|
||||
|
||||
|
||||
def test_camere_da_scaldare_db_inesistente(avvio, tmp_path):
|
||||
assert avvio.camere_da_scaldare(tmp_path / "non_esiste.db") == []
|
||||
|
||||
|
||||
def test_scalda_camere_una_chiamata_per_coppia(avvio, tmp_path, monkeypatch):
|
||||
db = tmp_path / "tmf.db"
|
||||
_db_camere(db)
|
||||
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||
chiamate = []
|
||||
|
||||
class Risposta:
|
||||
status = 200
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): return False
|
||||
|
||||
def finto_urlopen(richiesta, timeout=None):
|
||||
chiamate.append((richiesta.full_url, json.loads(richiesta.data), richiesta.get_method(), timeout))
|
||||
return Risposta()
|
||||
|
||||
monkeypatch.setattr(avvio.urllib.request, "urlopen", finto_urlopen)
|
||||
avvio.scalda_camere(db)
|
||||
corpo = {"trigger": "continuo", "timeout_ms": 5000}
|
||||
assert chiamate == [
|
||||
("http://127.0.0.1:8765/camera/grab", {"source": "camera:601020", **corpo}, "POST", 90),
|
||||
("http://10.0.0.5:8765/camera/grab", {"source": "camera:777", **corpo}, "POST", 90),
|
||||
]
|
||||
|
||||
|
||||
def test_scalda_camere_eccezione_urlopen_non_esce(avvio, tmp_path, monkeypatch):
|
||||
db = tmp_path / "tmf.db"
|
||||
_db_camere(db)
|
||||
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||
tentativi = []
|
||||
|
||||
def urlopen_rotto(richiesta, timeout=None):
|
||||
tentativi.append(richiesta.full_url)
|
||||
raise OSError("camera irraggiungibile")
|
||||
|
||||
monkeypatch.setattr(avvio.urllib.request, "urlopen", urlopen_rotto)
|
||||
avvio.scalda_camere(db) # non solleva e passa alla camera successiva
|
||||
assert len(tentativi) == 2
|
||||
+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,108 @@
|
||||
"""Archivio sorgente di TieMeasureFlow per un PC senza git (TieMeasureFlow#109).
|
||||
|
||||
Cosa fa
|
||||
Crea uno zip del repo a un commit, con dentro anche il submodule vendor/visionsuite (che
|
||||
`git archive` da solo NON include) e il file vision-engine-version.txt nella radice dello zip.
|
||||
Quel file contiene lo sha del submodule REGISTRATO nel commit (non la HEAD del checkout del
|
||||
submodule): packaging/windows/avvio.py lo legge e lo passa al worker di visione come
|
||||
VISION_ENGINE_VERSION; senza, il worker rifiuta ogni job. Formato: 40 caratteri ASCII,
|
||||
senza BOM e senza a capo (avvio.py usa .strip(), che non toglie un BOM).
|
||||
|
||||
Come si usa (dalla radice del repo, con il submodule inizializzato)
|
||||
uv run python scripts/archivio_sorgente.py --out C:/tmp/tmf.zip [--ref HEAD]
|
||||
Esce 0 e stampa lo sha; esce 1 con un messaggio e senza lasciare lo zip se il submodule
|
||||
non e' inizializzato o non ha il commit registrato (git submodule update --init vendor/visionsuite).
|
||||
|
||||
Dipende da: git nel PATH, solo libreria standard.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
SUBMODULE = "vendor/visionsuite"
|
||||
FILE_VERSIONE = "vision-engine-version.txt"
|
||||
|
||||
|
||||
class ErroreArchivio(RuntimeError):
|
||||
"""Errore chiaro da mostrare all'operatore (niente traceback)."""
|
||||
|
||||
|
||||
def _git(cartella: Path, *args: str, binario: bool = False):
|
||||
"""Esegue git in `cartella`; solleva ErroreArchivio con lo stderr se fallisce."""
|
||||
r = subprocess.run(["git", "-C", str(cartella), *args], capture_output=True)
|
||||
if r.returncode != 0:
|
||||
raise ErroreArchivio(f"git {' '.join(args)} fallito in {cartella}: {r.stderr.decode(errors='replace').strip()}")
|
||||
return r.stdout if binario else r.stdout.decode().strip()
|
||||
|
||||
|
||||
def sha_registrato(repo: Path, ref: str) -> str:
|
||||
"""Sha del submodule registrato nel commit `ref` (gitlink, modo 160000), non la HEAD del checkout."""
|
||||
campi = _git(repo, "ls-tree", ref, SUBMODULE).split()
|
||||
if len(campi) < 3 or campi[0] != "160000":
|
||||
raise ErroreArchivio(f"{SUBMODULE} non e' un submodule registrato in {ref}")
|
||||
return campi[2]
|
||||
|
||||
|
||||
def crea_archivio(repo: Path, out: Path, ref: str = "HEAD") -> str:
|
||||
"""Scrive lo zip in `out` e ritorna lo sha del submodule; in caso di errore non lascia nulla."""
|
||||
repo, out = Path(repo), Path(out)
|
||||
sha = sha_registrato(repo, ref)
|
||||
sub = repo / SUBMODULE
|
||||
# submodule non inizializzato o senza il commit registrato: errore prima di scrivere lo zip
|
||||
if not (sub / ".git").exists():
|
||||
raise ErroreArchivio(f"{SUBMODULE} non e' inizializzato: git submodule update --init {SUBMODULE}")
|
||||
if subprocess.run(["git", "-C", str(sub), "cat-file", "-e", f"{sha}^{{commit}}"], capture_output=True).returncode != 0:
|
||||
raise ErroreArchivio(f"{SUBMODULE} non ha il commit {sha}: git submodule update --init {SUBMODULE}")
|
||||
|
||||
zip_repo = _git(repo, "archive", "--format=zip", ref, binario=True)
|
||||
zip_sub = _git(sub, "archive", "--format=zip", f"--prefix={SUBMODULE}/", sha, binario=True)
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(suffix=".zip.tmp", dir=out.parent) # scrittura atomica: o c'e' tutto o niente
|
||||
os.close(fd)
|
||||
try:
|
||||
viste: set[str] = set()
|
||||
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for blob in (zip_repo, zip_sub):
|
||||
with zipfile.ZipFile(io.BytesIO(blob)) as sorgente:
|
||||
for info in sorgente.infolist():
|
||||
if info.filename in viste:
|
||||
continue # es. la cartella vendor/visionsuite/ del gitlink, gia' nel primo zip
|
||||
viste.add(info.filename)
|
||||
z.writestr(info, sorgente.read(info.filename), compress_type=zipfile.ZIP_DEFLATED)
|
||||
if FILE_VERSIONE in viste:
|
||||
raise ErroreArchivio(f"{FILE_VERSIONE} e' gia' nel repo a {ref}: va tolto dal versionamento")
|
||||
# 40 caratteri ASCII, senza BOM e senza a capo (avvio.py fa .strip(), che non toglie un BOM)
|
||||
z.writestr(FILE_VERSIONE, sha.encode("ascii"))
|
||||
os.replace(tmp, out)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
return sha
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Zip del sorgente con submodule e vision-engine-version.txt (#109)")
|
||||
p.add_argument("--out", required=True, type=Path, help="file .zip da creare")
|
||||
p.add_argument("--ref", default="HEAD", help="commit da archiviare (default HEAD)")
|
||||
a = p.parse_args(argv)
|
||||
radice = Path(__file__).resolve().parents[1]
|
||||
try:
|
||||
sha = crea_archivio(radice, a.out, a.ref)
|
||||
except ErroreArchivio as e:
|
||||
print(f"ERRORE: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"{a.out} creato; {FILE_VERSIONE} = {sha}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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,817 @@
|
||||
"""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
|
||||
POST /camera/calibrazioni/manuale json {source, nome, mm_per_px, sovrascrivi=false}
|
||||
-> 201 metadati (#95: mm/px impostato a mano, metodo "manuale", nessuna
|
||||
mappa; non la attiva). 400 mm/px fuori 0.00001..100 o nome non valido,
|
||||
409 nome gia' usato; la risoluzione e' quella del frame tenuto o di un grab
|
||||
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)
|
||||
GET /visore/{image_viewer.js|save_image.js|vision_overlay.js} -> i JS del <tmf-viewer> del frontend (#122), altri nomi 404
|
||||
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.
|
||||
#110: specchiatura per sorgente, in software (cv2.flip) su OGNI frame letto dalla
|
||||
sorgente, prima di raddrizzatura/calibrazione (anteprima, grab, calibrazione da camera,
|
||||
copia bloccata); non tocca l'immagine caricata a mano in /camera/calibrate.
|
||||
GET /camera/orientamento?source=S -> {source, orizzontale, verticale} (default false/false);
|
||||
400 se manca source
|
||||
PUT /camera/orientamento json {source, orizzontale: bool, verticale: bool} -> come GET +
|
||||
"avviso" (stringa o null: la calibrazione attiva ha una mappa fatta con l'orientamento
|
||||
di prima e va rifatta). 400 se source manca o i due campi non sono bool. Salva subito
|
||||
su disco; scarta i frame tenuti (anteprima/blocca).
|
||||
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json, l'orientamento
|
||||
per sorgente in orientamento.json (#110) (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).
|
||||
Immagini caricate (#92): POST/DELETE /camera/immagini, in <config-dir>/immagini; sono sorgenti
|
||||
a pieno titolo (/camera/sources, grab, anteprima, calibrate). POST /camera/anteprima/calibrata
|
||||
raddrizza l'ultimo frame grezzo dell'anteprima con la calibrazione attiva, senza leggerne uno nuovo.
|
||||
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 json # #110: orientamento.json
|
||||
import logging
|
||||
import math # #95: mm/px finito nella calibrazione manuale
|
||||
import os
|
||||
import re # #92: nome sicuro per le immagini caricate
|
||||
import sys
|
||||
import threading
|
||||
import time # #92: scadenza della cache delle camere
|
||||
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
|
||||
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 _ESTENSIONI, SourceNotFound, list_sources, open_source # #92: _ESTENSIONI = tipi ammessi per le immagini caricate
|
||||
|
||||
# #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
|
||||
|
||||
# #122: i tre file JS del frontend che la pagina carica su /visore/<nome> (solo questi nomi: nessun percorso arbitrario dall'URL).
|
||||
# Cartelle cercate nell'ordine: pacchetto Nuitka (static/ accanto al modulo, copiata da build.ps1) e sorgenti (src/frontend/flask_app/static/js)
|
||||
_FILE_VISORE = ("image_viewer.js", "save_image.js", "vision_overlay.js")
|
||||
_QUI = Path(__file__).resolve().parent
|
||||
_CARTELLE_VISORE = [_QUI / "static" / "js", _QUI.parent / "src" / "frontend" / "flask_app" / "static" / "js"]
|
||||
|
||||
_TRIGGER = ("continuo",)
|
||||
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
|
||||
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
|
||||
_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 = {}
|
||||
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
|
||||
bloccati: dict = {} # #94: sorgente -> copia del frame grezzo pieno «bloccata» per il calcolo (stesso lock di grezzi)
|
||||
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
|
||||
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()
|
||||
file_orientamento = archivio.cartella / "orientamento.json" # #110: {sorgente: {orizzontale, verticale}}
|
||||
|
||||
def _carica_orientamenti() -> dict:
|
||||
"""#110: orientamenti salvati; file assente o rotto -> {} (mai eccezione)."""
|
||||
if not file_orientamento.is_file():
|
||||
return {}
|
||||
try:
|
||||
dati = json.loads(file_orientamento.read_text(encoding="utf-8"))
|
||||
if not isinstance(dati, dict):
|
||||
raise ValueError("non e' un oggetto JSON")
|
||||
return {n: {"orizzontale": v.get("orizzontale") is True, "verticale": v.get("verticale") is True}
|
||||
for n, v in dati.items() if isinstance(n, str) and isinstance(v, dict)}
|
||||
except (OSError, ValueError) as e:
|
||||
_log.warning("orientamento non caricato (%s): %s", file_orientamento, e)
|
||||
return {}
|
||||
|
||||
orientamenti: dict = _carica_orientamenti() # #110: sorgente -> {"orizzontale": bool, "verticale": bool}; protetto da `lock`
|
||||
|
||||
def _specchia(nome: str, frame):
|
||||
"""#110: applica l'orientamento della sorgente al frame letto; nessuno -> lo stesso oggetto, senza copia."""
|
||||
with lock:
|
||||
o = orientamenti.get(nome) or {}
|
||||
orizz, vert = o.get("orizzontale", False), o.get("verticale", False)
|
||||
if orizz and vert:
|
||||
return cv2.flip(frame, -1) # entrambi = rotazione di 180 gradi
|
||||
if orizz:
|
||||
return cv2.flip(frame, 1)
|
||||
if vert:
|
||||
return cv2.flip(frame, 0)
|
||||
return frame
|
||||
|
||||
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())
|
||||
|
||||
# #92: la ricerca camere di list_sources dura ~10 s: il risultato (gia' filtrato) resta 60 s in cache
|
||||
cache_camere: dict = {"voci": None, "t": 0.0}
|
||||
lock_ricerca = threading.Lock() # due richieste insieme non lanciano due ricerche
|
||||
|
||||
def _camere(aggiorna: bool = False) -> list:
|
||||
with lock_ricerca:
|
||||
fresca = cache_camere["voci"] is not None and time.monotonic() - cache_camere["t"] < _CACHE_CAMERE_S
|
||||
if fresca and not aggiorna:
|
||||
return cache_camere["voci"]
|
||||
voci = list_sources(None, cameras=True, config=_config())
|
||||
# camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
|
||||
cache_camere["voci"] = [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
|
||||
cache_camere["t"] = time.monotonic()
|
||||
return cache_camere["voci"]
|
||||
|
||||
def _prima_ricerca() -> None:
|
||||
try:
|
||||
_camere()
|
||||
except Exception: # noqa: BLE001 - e' solo un riscaldamento: la prima richiesta riprova
|
||||
_log.exception("prima ricerca camere fallita")
|
||||
|
||||
@asynccontextmanager
|
||||
async def _vita(_app):
|
||||
# #92: la prima ricerca parte all'avvio, cosi' la prima pagina non aspetta
|
||||
threading.Thread(target=_prima_ricerca, daemon=True, name="ricerca-camere").start()
|
||||
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(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
|
||||
elenco = list(_camere(aggiorna))
|
||||
# #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
|
||||
for v in list_sources(cartella_immagini, cameras=False):
|
||||
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
|
||||
return {"sources": elenco}
|
||||
|
||||
def _chiudi(nome: str) -> None:
|
||||
"""#92: chiude la sorgente aperta e scorda il frame tenuto (immagine sostituita o tolta)."""
|
||||
with _lock_di(nome):
|
||||
with lock:
|
||||
s = aperte.pop(nome, None)
|
||||
grezzi.pop(nome, None)
|
||||
bloccati.pop(nome, None) # #94: tolta l'immagine/chiusa la sorgente, anche la copia bloccata
|
||||
if s is not None:
|
||||
try:
|
||||
s.close()
|
||||
except Exception: # noqa: BLE001 - la chiusura non deve far fallire la rotta
|
||||
pass
|
||||
|
||||
def _nome_immagine(grezzo: str) -> str:
|
||||
"""#92: solo il nome del file, coi caratteri sicuri di Archivio._file; "" se non ammesso."""
|
||||
base = Path((grezzo or "").replace("\\", "/")).name
|
||||
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("._")
|
||||
radice, punto, estensione = pulito.rpartition(".")
|
||||
if not punto or not radice or f".{estensione.lower()}" not in _ESTENSIONI:
|
||||
return ""
|
||||
return f"{radice[:100]}.{estensione}"
|
||||
|
||||
@app.post("/camera/immagini", status_code=201)
|
||||
async def immagini_carica(image: UploadFile | None = File(None)):
|
||||
if image is None:
|
||||
raise HTTPException(400, "manca il file «image»")
|
||||
nome = _nome_immagine(image.filename or "")
|
||||
if not nome:
|
||||
raise HTTPException(400, "estensione non ammessa: " + ", ".join(_ESTENSIONI))
|
||||
contenuto = await image.read()
|
||||
# #92: 422 se non e' un'immagine; la verifica sta PRIMA della scrittura, cosi' non resta nulla su disco
|
||||
if not contenuto or cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED) is None:
|
||||
raise HTTPException(422, "il file caricato non è leggibile come immagine")
|
||||
cartella_immagini.mkdir(parents=True, exist_ok=True)
|
||||
(cartella_immagini / nome).write_bytes(contenuto) # stesso nome = sostituisce
|
||||
await run_in_threadpool(_chiudi, nome) # se era aperta, si riapre sul file nuovo
|
||||
return {"source": nome, "label": f"immagine — {nome}", "kind": "image"}
|
||||
|
||||
@app.delete("/camera/immagini", status_code=204)
|
||||
def immagini_elimina(nome: str = ""):
|
||||
percorso = cartella_immagini / nome
|
||||
# #92: solo il nome di un file dentro la cartella, niente percorsi
|
||||
if not nome or Path(nome).name != nome or not percorso.is_file():
|
||||
raise HTTPException(404, "immagine non trovata")
|
||||
_chiudi(nome)
|
||||
percorso.unlink()
|
||||
return Response(status_code=204)
|
||||
|
||||
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, cartella_immagini, config=_config()) # #92: un nome di file = immagine caricata
|
||||
if hasattr(sorgente, "loop"): # #92: l'immagine singola non deve "finire" al secondo grab (anteprima continua)
|
||||
sorgente.loop = True
|
||||
# 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: _specchia(nome, s.grab(timeout))) # #87: corpo in _con_sorgente; #110: specchiato prima di tutto il resto
|
||||
|
||||
@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)
|
||||
bloccata: bool = Form(False), # #94: true = calcola sulla copia bloccata, senza grab
|
||||
):
|
||||
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")
|
||||
elif bloccata: # #94: copia bloccata, nessun grab e nessun lock camera
|
||||
nome = _sorgente(source)
|
||||
with lock:
|
||||
frame = bloccati.get(nome)
|
||||
if frame is None:
|
||||
raise HTTPException(409, "nessuna immagine bloccata su questa sorgente")
|
||||
origine = nome
|
||||
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"],
|
||||
"bloccata": bool(bloccata and not contenuto)} # #94: solo nella risposta (non nel salvato): true se il frame e' la copia bloccata
|
||||
|
||||
# ---- #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.post("/camera/calibrazioni/manuale", status_code=201)
|
||||
def calibrazioni_manuale(corpo: dict = Body(default=None)):
|
||||
"""#95: calibrazione con mm/px impostato a mano (nessuna scacchiera, nessuna mappa); NON la attiva."""
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
sorgente = _sorgente(corpo.get("source"))
|
||||
mm_px = corpo.get("mm_per_px")
|
||||
if (isinstance(mm_px, bool) or not isinstance(mm_px, (int, float))
|
||||
or not math.isfinite(mm_px) or not 0.00001 <= mm_px <= 100):
|
||||
raise HTTPException(400, "mm/px deve essere un numero positivo")
|
||||
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:
|
||||
frame = grezzi.get(sorgente)
|
||||
if frame is None: # nessun frame tenuto: un grab come /camera/grab (404/503/504)
|
||||
frame = _grab(sorgente, 5000)
|
||||
alt, larg = frame.shape[:2] # serve al controllo W×H di PUT /camera/calibrazione-attiva
|
||||
dati = {"px_per_mm": 1.0 / mm_px, "mm_per_px": float(mm_px), "metodo": "manuale",
|
||||
"image_width": larg, "image_height": alt, "origine": sorgente,
|
||||
"calibrated_at": calibrazione.ora_iso(), "rms_px": None, "square_mm": None}
|
||||
try:
|
||||
return archivio.salva_nome(nome, dati, None, 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")
|
||||
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
|
||||
f = _specchia(nome, s.grab(timeout)) # #110: specchiato PRIMA di tenerlo, cosi' blocca/calibrata lo ricevono gia' specchiato
|
||||
with lock:
|
||||
grezzi[nome] = f.copy()
|
||||
return f
|
||||
frame = _con_sorgente(nome, leggi)
|
||||
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.post("/camera/blocca")
|
||||
def camera_blocca(corpo: dict = Body(default=None)):
|
||||
"""#94: copia l'ultimo frame grezzo della sorgente; /camera/calibrate con bloccata=true calcola su quella."""
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
with lock:
|
||||
frame = grezzi.get(nome)
|
||||
if frame is None:
|
||||
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
|
||||
bloccati[nome] = frame.copy()
|
||||
alt, larg = frame.shape[:2]
|
||||
return {"source": nome, "larghezza": larg, "altezza": alt}
|
||||
|
||||
@app.delete("/camera/blocca", status_code=204)
|
||||
def camera_sblocca(source: str = ""):
|
||||
with lock: # #94: idempotente, 204 anche se non c'era nulla
|
||||
bloccati.pop(source.strip(), None)
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.post("/camera/anteprima/calibrata")
|
||||
def camera_anteprima_calibrata(corpo: dict = Body(default=None)):
|
||||
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo. #95: senza mappa esce grezzo (X-Raddrizzato: no)."""
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
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")
|
||||
with lock:
|
||||
frame = grezzi.get(nome)
|
||||
if frame is None:
|
||||
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
|
||||
attiva = archivio.attiva_di(nome)
|
||||
if attiva is None:
|
||||
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione
|
||||
dati = archivio.leggi(nome) or {}
|
||||
cw, ch = dati.get("image_width"), dati.get("image_height")
|
||||
alt, larg = frame.shape[:2]
|
||||
if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt):
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||
f"il frame è {larg}×{alt}")
|
||||
corretto, raddrizzato = frame, "no"
|
||||
else:
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is None: # risoluzione diversa da quella calibrata
|
||||
ch, cw = mappa.shape[:2]
|
||||
alt, larg = frame.shape[:2]
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||
f"il frame è {larg}×{alt}")
|
||||
raddrizzato = "si"
|
||||
alt, larg = corretto.shape[:2]
|
||||
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
|
||||
corretto = _grigio8(corretto)
|
||||
scala = lato / max(alt, larg)
|
||||
if scala < 1.0: # mai ingrandita
|
||||
corretto = cv2.resize(corretto, (max(1, round(larg * scala)), max(1, round(alt * scala))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
ok, buf = cv2.imencode(".jpg", corretto, [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": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
|
||||
|
||||
@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)
|
||||
|
||||
# ---- #110: specchiatura orizzontale/verticale per sorgente
|
||||
|
||||
def _orientamento_di(nome: str) -> dict:
|
||||
with lock:
|
||||
o = orientamenti.get(nome) or {}
|
||||
return {"source": nome, "orizzontale": bool(o.get("orizzontale")), "verticale": bool(o.get("verticale"))}
|
||||
|
||||
@app.get("/camera/orientamento")
|
||||
def orientamento_leggi(source: str = ""):
|
||||
return _orientamento_di(_sorgente(source))
|
||||
|
||||
@app.put("/camera/orientamento")
|
||||
def orientamento_scrivi(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
orizz, vert = corpo.get("orizzontale"), corpo.get("verticale")
|
||||
if not isinstance(orizz, bool) or not isinstance(vert, bool):
|
||||
raise HTTPException(400, "«orizzontale» e «verticale» devono essere true o false")
|
||||
with lock:
|
||||
prima = orientamenti.get(nome) or {}
|
||||
prima_voce = orientamenti.get(nome) # #110: voce di prima (None = non c'era), per il ripristino se il salvataggio fallisce
|
||||
cambiato = (bool(prima.get("orizzontale")), bool(prima.get("verticale"))) != (orizz, vert)
|
||||
if orizz or vert:
|
||||
orientamenti[nome] = {"orizzontale": orizz, "verticale": vert}
|
||||
else:
|
||||
orientamenti.pop(nome, None) # tutti false: la sorgente esce dal file
|
||||
# scrittura atomica di tutto il dizionario (tmp + replace)
|
||||
try:
|
||||
file_orientamento.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = file_orientamento.with_name(file_orientamento.name + ".tmp")
|
||||
tmp.write_text(json.dumps(orientamenti, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, file_orientamento)
|
||||
except OSError as e:
|
||||
_log.warning("orientamento non salvato (%s): %s", file_orientamento, e)
|
||||
if prima_voce is None: # #110: salvataggio fallito -> in memoria resta l'orientamento di prima
|
||||
orientamenti.pop(nome, None)
|
||||
else:
|
||||
orientamenti[nome] = prima_voce
|
||||
raise HTTPException(500, "salvataggio dell'orientamento non riuscito")
|
||||
# i frame tenuti hanno l'orientamento di prima
|
||||
grezzi.pop(nome, None)
|
||||
bloccati.pop(nome, None)
|
||||
avviso = None
|
||||
if cambiato and archivio.leggi_mappa(nome) is not None:
|
||||
avviso = (f"la calibrazione attiva «{archivio.attiva_di(nome)}» corregge la distorsione "
|
||||
"con l'orientamento di prima: va rifatta")
|
||||
return {**_orientamento_di(nome), "avviso": avviso}
|
||||
|
||||
@app.get("/calibrazione", response_class=HTMLResponse)
|
||||
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
|
||||
def calibrazione_pagina():
|
||||
return HTMLResponse(PAGINA)
|
||||
|
||||
@app.get("/visore/{nome}")
|
||||
def visore_file(nome: str):
|
||||
"""#122: i file JS del <tmf-viewer> letti dal frontend Flask; la pagina resta offline."""
|
||||
if nome not in _FILE_VISORE: # lista chiusa: niente percorsi dall'URL
|
||||
raise HTTPException(404, "file non trovato")
|
||||
for cartella in _CARTELLE_VISORE:
|
||||
f = cartella / nome
|
||||
if f.is_file():
|
||||
return Response(f.read_bytes(), media_type="application/javascript", headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(404, "file non trovato")
|
||||
|
||||
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,92 @@
|
||||
"""Test di scripts/archivio_sorgente.py (TieMeasureFlow#109): repo temporaneo con un submodule finto.
|
||||
|
||||
Uso: uv run --no-project --python 3.13 --with pytest pytest -q scripts/tests/test_archivio_sorgente.py
|
||||
"""
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"archivio_sorgente", Path(__file__).resolve().parents[1] / "archivio_sorgente.py")
|
||||
archivio = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(archivio)
|
||||
|
||||
GIT = ["git", "-c", "core.autocrlf=false", "-c", "protocol.file.allow=always",
|
||||
"-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]
|
||||
|
||||
|
||||
def _g(cartella, *args):
|
||||
r = subprocess.run([*GIT, "-C", str(cartella), *args], capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stderr
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def _repo(tmp_path, nome):
|
||||
d = tmp_path / nome
|
||||
d.mkdir()
|
||||
_g(d, "init", "-q", "-b", "main")
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def padre(tmp_path):
|
||||
"""Repo padre con vendor/visionsuite registrato (submodule finto da un repo locale)."""
|
||||
origine = _repo(tmp_path, "origine")
|
||||
(origine / "motore.py").write_text("x = 1\n")
|
||||
_g(origine, "add", ".")
|
||||
_g(origine, "commit", "-q", "-m", "motore")
|
||||
p = _repo(tmp_path, "padre")
|
||||
(p / "app.py").write_text("print('tmf')\n")
|
||||
_g(p, "add", ".")
|
||||
_g(p, "commit", "-q", "-m", "app")
|
||||
_g(p, "submodule", "add", "-q", str(origine), "vendor/visionsuite")
|
||||
_g(p, "commit", "-q", "-m", "submodule")
|
||||
return p
|
||||
|
||||
|
||||
def test_zip_con_repo_submodule_e_versione(padre, tmp_path):
|
||||
atteso = _g(padre, "ls-tree", "HEAD", "vendor/visionsuite").split()[2]
|
||||
out = tmp_path / "o" / "tmf.zip"
|
||||
assert archivio.crea_archivio(padre, out) == atteso
|
||||
with zipfile.ZipFile(out) as z:
|
||||
nomi = z.namelist()
|
||||
assert "app.py" in nomi and "vendor/visionsuite/motore.py" in nomi
|
||||
dati = z.read("vision-engine-version.txt")
|
||||
assert dati == atteso.encode("ascii") and len(dati) == 40
|
||||
assert not dati.startswith(b"\xef\xbb\xbf") and b"\n" not in dati # niente BOM, niente a capo
|
||||
|
||||
|
||||
def test_sha_resta_quello_registrato_se_il_submodule_avanza(padre, tmp_path):
|
||||
registrato = _g(padre, "ls-tree", "HEAD", "vendor/visionsuite").split()[2]
|
||||
sub = padre / "vendor" / "visionsuite"
|
||||
(sub / "nuovo.py").write_text("y = 2\n")
|
||||
_g(sub, "add", ".")
|
||||
_g(sub, "commit", "-q", "-m", "avanti non registrato")
|
||||
assert _g(sub, "rev-parse", "HEAD") != registrato
|
||||
out = tmp_path / "tmf.zip"
|
||||
assert archivio.crea_archivio(padre, out) == registrato
|
||||
with zipfile.ZipFile(out) as z:
|
||||
assert z.read("vision-engine-version.txt").decode() == registrato
|
||||
assert "vendor/visionsuite/nuovo.py" not in z.namelist() # contenuto del commit registrato
|
||||
|
||||
|
||||
def test_submodule_non_inizializzato_errore_e_nessuno_zip(padre, tmp_path):
|
||||
clone = tmp_path / "clone"
|
||||
_g(tmp_path, "clone", "-q", str(padre), str(clone)) # senza --recurse-submodules
|
||||
out = tmp_path / "tmf.zip"
|
||||
with pytest.raises(archivio.ErroreArchivio, match="inizializzato"):
|
||||
archivio.crea_archivio(clone, out)
|
||||
assert not out.exists() and not list(tmp_path.glob("*.tmp")) # nessuno zip ne' temporaneo
|
||||
|
||||
|
||||
def test_cli_esce_1_con_messaggio(padre, tmp_path, capsys, monkeypatch):
|
||||
clone = tmp_path / "clone"
|
||||
_g(tmp_path, "clone", "-q", str(padre), str(clone))
|
||||
originale = archivio.crea_archivio
|
||||
# la CLI lavora sul repo che contiene lo script: la si dirotta sul clone senza submodule
|
||||
monkeypatch.setattr(archivio, "crea_archivio", lambda r, o, ref="HEAD": originale(clone, o, ref))
|
||||
assert archivio.main(["--out", str(tmp_path / "x.zip")]) == 1
|
||||
assert "ERRORE" in capsys.readouterr().err
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Test di #94: «Blocca immagine» (scripts/camera_service.py) e riquadro vivo nella tab Calibrazione (PAGINA).
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Sorgente finta che conta i grab: niente camera vera, niente ProgramData.
|
||||
"""
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from test_calibrazione import _png, cartella, client, una_scacchiera # fixture e helper della calibrazione
|
||||
from test_immagini_affiancate import _anteprima, _carica, _modulo
|
||||
from test_pagina_acquisizione import PAGINA
|
||||
|
||||
__all__ = ["cartella", "client"] # le fixture importate restano usate
|
||||
|
||||
FINTA = "finta"
|
||||
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finta(client, monkeypatch):
|
||||
"""Sorgente «finta»: il primo grab da' la scacchiera, i successivi un frame bianco (non calibrabile)."""
|
||||
letture = []
|
||||
scacchiera = una_scacchiera()
|
||||
bianco = np.full(scacchiera.shape, 255, np.uint8)
|
||||
|
||||
class Finta:
|
||||
def grab(self, timeout=1000):
|
||||
letture.append(1)
|
||||
return scacchiera.copy() if len(letture) == 1 else bianco.copy()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setitem(_modulo(client), "open_source", lambda *a, **k: Finta())
|
||||
client.letture = letture
|
||||
return client
|
||||
|
||||
|
||||
def _blocca(c, nome=FINTA):
|
||||
return c.post("/camera/blocca", json={"source": nome})
|
||||
|
||||
|
||||
def _calcola(c, **extra):
|
||||
return c.post("/camera/calibrate", data={"source": FINTA, **CAMPI, **extra})
|
||||
|
||||
|
||||
# ---- servizio
|
||||
|
||||
def test_blocca_senza_frame_404(finta):
|
||||
r = _blocca(finta)
|
||||
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
|
||||
|
||||
|
||||
def test_blocca_senza_source_400(finta):
|
||||
assert finta.post("/camera/blocca", json={}).status_code == 400
|
||||
assert finta.post("/camera/blocca").status_code == 400
|
||||
|
||||
|
||||
def test_anteprima_poi_blocca_200_con_misure(finta):
|
||||
assert _anteprima(finta, FINTA).status_code == 200
|
||||
r = _blocca(finta)
|
||||
alt, larg = una_scacchiera().shape[:2]
|
||||
assert r.status_code == 200 and r.json() == {"source": FINTA, "larghezza": larg, "altezza": alt}
|
||||
|
||||
|
||||
def test_calibrate_bloccata_usa_la_copia_senza_grab(finta):
|
||||
assert _anteprima(finta, FINTA).status_code == 200 # frame 1: la scacchiera
|
||||
assert _blocca(finta).status_code == 200
|
||||
assert _anteprima(finta, FINTA).status_code == 200 # frame 2: bianco, il grezzo cambia ma la copia no
|
||||
prima = len(finta.letture)
|
||||
r = _calcola(finta, bloccata="true")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["bloccata"] is True and r.json()["origine"] == FINTA
|
||||
assert len(finta.letture) == prima # nessun grab per il calcolo
|
||||
# senza il blocco il calcolo legge un frame nuovo (bianco): non calibrabile
|
||||
assert _calcola(finta).status_code == 422
|
||||
assert len(finta.letture) == prima + 1
|
||||
|
||||
|
||||
def test_calibrate_normale_dice_bloccata_false(client):
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["bloccata"] is False
|
||||
|
||||
|
||||
def test_calibrate_bloccata_senza_copia_409(finta):
|
||||
assert _anteprima(finta, FINTA).status_code == 200 # il grezzo c'e', ma non e' bloccato
|
||||
r = _calcola(finta, bloccata="true")
|
||||
assert r.status_code == 409 and r.json()["detail"] == "nessuna immagine bloccata su questa sorgente"
|
||||
|
||||
|
||||
def test_sblocca_204_idempotente_e_toglie_la_copia(finta):
|
||||
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204 # niente da togliere
|
||||
assert _anteprima(finta, FINTA).status_code == 200
|
||||
assert _blocca(finta).status_code == 200
|
||||
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
|
||||
assert _calcola(finta, bloccata="true").status_code == 409
|
||||
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
|
||||
|
||||
|
||||
def test_rimuovere_l_immagine_toglie_la_copia_bloccata(client):
|
||||
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
|
||||
assert _anteprima(client, "foto.png").status_code == 200
|
||||
assert _blocca(client, "foto.png").status_code == 200
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
|
||||
r = client.post("/camera/calibrate", data={"source": "foto.png", "bloccata": "true", **CAMPI})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
# ---- pagina (contenuto statico)
|
||||
|
||||
def test_pagina_riquadro_e_casella_nella_tab_calibrazione():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
assert {"immagineCalTab", "vuotoCalTab", "scattaCal", "bloccaImg", "msgBlocco"} <= ids
|
||||
assert "Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione" in PAGINA
|
||||
assert "Blocca immagine" in PAGINA
|
||||
tab = PAGINA.split('id="pannelloCal"')[1].split('id="sezioneSalvate"')[0]
|
||||
assert "immagineCalTab" in tab and "bloccaImg" in tab # nella sezione Calibrazione, non altrove
|
||||
|
||||
|
||||
def test_pagina_scegli_tab_non_ferma_piu_il_ciclo():
|
||||
assert "A.riprendi" not in PAGINA
|
||||
scegli = PAGINA.split("function scegliTab")[1].split("$(\"tabAcq\").onclick")[0]
|
||||
assert "fermaAnteprima" not in scegli and "avviaAnteprima" not in scegli
|
||||
|
||||
|
||||
def test_pagina_calcola_manda_bloccata_e_non_ferma_il_ciclo():
|
||||
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||
assert 'f.append("bloccata", "true")' in calcola
|
||||
assert "if (bloccata)" in calcola and "else fermaAnteprima();" in calcola
|
||||
assert "immagine bloccata" in calcola
|
||||
assert "l'anteprima resta sospesa" not in PAGINA and "si può bloccare l'immagine" in PAGINA
|
||||
|
||||
|
||||
def test_pagina_blocco_rotte_e_cambio_sorgente():
|
||||
assert '"/camera/blocca"' in PAGINA and '"/camera/blocca?source="' in PAGINA
|
||||
assert "method: \"DELETE\"" in PAGINA
|
||||
cambio = PAGINA.split("function cambioSorgente")[1].split("// ---- Anteprima")[0]
|
||||
assert "rilasciaBlocco()" in cambio
|
||||
assert cambio.index("rilasciaBlocco()") < cambio.index("svuotaRiquadri()")
|
||||
|
||||
|
||||
def test_pagina_blocco_attende_l_anteprima_in_volo():
|
||||
# #94: il frame bloccato e' quello mostrato: flag A.bloccando, attesa di A.inVolo (20 ms, max 3 s), poi POST, flag tolto in finally
|
||||
blocco = PAGINA.split('$("bloccaImg").onchange')[1].split('$("avvia").onclick')[0]
|
||||
assert "A.bloccando++" in blocco and "A.bloccando--" in blocco and "finally" in blocco
|
||||
assert "k < 150" in blocco and "pausa(20)" in blocco
|
||||
assert blocco.index("A.inVolo") < blocco.index('fetch("/camera/blocca"')
|
||||
ciclo = PAGINA.split("async function ciclo")[1].split("function avviaAnteprima")[0]
|
||||
# #111: il lato dell'anteprima segue lo zoom (latoAnteprima(), 1280 senza zoom)
|
||||
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(latoAnteprima())")
|
||||
scatta = PAGINA.split("async function scatta")[1].split("// #94: «Blocca immagine»")[0]
|
||||
assert "while (A.bloccando)" in scatta and scatta.index("A.bloccando") < scatta.index("unaAnteprima(4096)")
|
||||
@@ -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", "bloccata"} == set(corpo) # #94: + bloccata; #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", "bloccata"} == 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,148 @@
|
||||
"""Test di #95: calibrazione manuale (mm/px impostato a mano) in scripts/camera_service.py e nella PAGINA.
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Sorgenti = immagini caricate in tmp_path: niente camera vera, niente ProgramData.
|
||||
"""
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from test_calibrazione import _png, cartella, client # fixture della calibrazione
|
||||
from test_immagini_affiancate import _anteprima, _calibrata, _carica
|
||||
from test_pagina_acquisizione import PAGINA
|
||||
|
||||
__all__ = ["cartella", "client"] # le fixture importate restano usate
|
||||
|
||||
SRC = "banco.png" # 64x48
|
||||
ALTRA = "grande.png" # 80x60, risoluzione diversa
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sorgenti(client):
|
||||
assert _carica(client, SRC, np.full((48, 64), 120, np.uint8)).status_code == 201
|
||||
assert _carica(client, ALTRA, np.full((60, 80), 120, np.uint8)).status_code == 201
|
||||
return client
|
||||
|
||||
|
||||
def _manuale(c, nome="Manuale", mm=0.05, source=SRC, **extra):
|
||||
return c.post("/camera/calibrazioni/manuale",
|
||||
json={"source": source, "nome": nome, "mm_per_px": mm, **extra})
|
||||
|
||||
|
||||
# ---- servizio
|
||||
|
||||
def test_manuale_201_con_scala_e_risoluzione_del_frame(sorgenti):
|
||||
r = _manuale(sorgenti, mm=0.05)
|
||||
assert r.status_code == 201, r.text
|
||||
m = r.json()
|
||||
assert m["nome"] == "Manuale" and m["metodo"] == "manuale"
|
||||
assert m["px_per_mm"] == pytest.approx(20.0) and m["mm_per_px"] == pytest.approx(0.05)
|
||||
assert (m["image_width"], m["image_height"]) == (64, 48)
|
||||
assert m["origine"] == SRC and m["square_mm"] is None and m["attiva_su"] == [] # non la attiva
|
||||
voci = sorgenti.get("/camera/calibrazioni").json()["calibrazioni"]
|
||||
assert [v["nome"] for v in voci] == ["Manuale"]
|
||||
|
||||
|
||||
def test_manuale_usa_il_frame_tenuto_se_c_e(sorgenti):
|
||||
assert _anteprima(sorgenti, SRC).status_code == 200 # tiene il grezzo 64x48
|
||||
r = _manuale(sorgenti)
|
||||
assert r.status_code == 201 and (r.json()["image_width"], r.json()["image_height"]) == (64, 48)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("valore", [0, -1, 0.000001, 100.5, "abc", None, True])
|
||||
def test_manuale_mm_non_valido_400(sorgenti, valore):
|
||||
r = _manuale(sorgenti, mm=valore)
|
||||
assert r.status_code == 400
|
||||
assert "mm/px deve essere un numero positivo" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_manuale_senza_source_o_nome_400_e_sorgente_ignota_404(sorgenti):
|
||||
assert sorgenti.post("/camera/calibrazioni/manuale", json={"nome": "x", "mm_per_px": 0.1}).status_code == 400
|
||||
assert _manuale(sorgenti, nome=" ").status_code == 400
|
||||
assert _manuale(sorgenti, source="nonesiste.png").status_code == 404
|
||||
|
||||
|
||||
def test_manuale_409_nome_doppio_e_sovrascrivi(sorgenti):
|
||||
assert _manuale(sorgenti, mm=0.05).status_code == 201
|
||||
r = _manuale(sorgenti, mm=0.1)
|
||||
assert r.status_code == 409 and "esiste già una calibrazione" in r.json()["detail"]
|
||||
r = _manuale(sorgenti, mm=0.1, sovrascrivi=True)
|
||||
assert r.status_code == 201 and r.json()["px_per_mm"] == pytest.approx(10.0)
|
||||
assert _manuale(sorgenti, mm=0.1, sovrascrivi="si").status_code == 400
|
||||
|
||||
|
||||
def test_attiva_manuale_ok_su_stessa_risoluzione_e_legge_px_per_mm(sorgenti):
|
||||
assert _manuale(sorgenti, mm=0.05).status_code == 201
|
||||
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"})
|
||||
assert r.status_code == 200, r.text
|
||||
g = sorgenti.get("/camera/calibration", params={"source": SRC})
|
||||
assert g.status_code == 200 and g.json()["px_per_mm"] == pytest.approx(20.0)
|
||||
assert g.json()["nome"] == "Manuale" and g.json()["metodo"] == "manuale"
|
||||
|
||||
|
||||
def test_attiva_manuale_409_su_risoluzione_diversa(sorgenti):
|
||||
assert _manuale(sorgenti).status_code == 201
|
||||
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": ALTRA, "nome": "Manuale"})
|
||||
assert r.status_code == 409 and "64×48" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_anteprima_calibrata_con_manuale_200_grezza(sorgenti):
|
||||
assert _manuale(sorgenti).status_code == 201
|
||||
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
|
||||
assert _anteprima(sorgenti, SRC).status_code == 200
|
||||
r = _calibrata(sorgenti, SRC)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["X-Raddrizzato"] == "no" and r.headers["X-Calibrazione"] == "Manuale"
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("64", "48")
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
|
||||
|
||||
def test_anteprima_calibrata_manuale_409_se_il_frame_ha_altra_risoluzione(sorgenti):
|
||||
assert _manuale(sorgenti).status_code == 201
|
||||
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
|
||||
assert _anteprima(sorgenti, ALTRA).status_code == 200
|
||||
# stessa sorgente attiva, ma il frame tenuto cambia (immagine sostituita con altra misura)
|
||||
assert _carica(sorgenti, SRC, np.full((60, 80), 9, np.uint8)).status_code == 201
|
||||
assert _anteprima(sorgenti, SRC).status_code == 200
|
||||
r = _calibrata(sorgenti, SRC)
|
||||
assert r.status_code == 409 and "64×48" in r.json()["detail"]
|
||||
|
||||
|
||||
# ---- pagina
|
||||
|
||||
def test_pagina_scheda_manuale_id_e_testi():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
assert {"mmPx", "pxMmCalc", "nomeManuale", "salvaManuale", "msgManuale"} <= ids
|
||||
tab = PAGINA.split('id="pannelloCal"')[1]
|
||||
assert tab.index('id="sezioneCalibrazione"') < tab.index('id="sezioneManuale"') < tab.index('id="sezioneSalvate"')
|
||||
assert "Calibrazione manuale" in PAGINA and "mm per pixel" in PAGINA
|
||||
assert "salvata: per usarla «Carica» o CONFERMA" in PAGINA
|
||||
assert "Sovrascrivi «" in PAGINA.split("async function salvaManuale")[1] # stessa conferma del Salva scacchiera
|
||||
assert "/camera/calibrazioni/manuale" in PAGINA and "alert(" not in PAGINA and "confirm(" not in PAGINA
|
||||
|
||||
|
||||
def test_pagina_manuale_senza_sorgente_pulsante_spento_con_nota():
|
||||
stato = PAGINA.split("function statoManuale")[1].split("function aggiornaManuale")[0]
|
||||
assert 'salvaManuale").disabled = !s' in stato and "Scegliere una sorgente" in stato
|
||||
assert "statoManuale()" in PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
|
||||
|
||||
|
||||
def test_pagina_px_mm_calcolato_a_ogni_tasto():
|
||||
assert '$("mmPx").oninput = aggiornaManuale' in PAGINA
|
||||
assert '"= " + fmt(1 / v, 4) + " px/mm"' in PAGINA
|
||||
|
||||
|
||||
def test_pagina_metodo_manuale_e_campi_null():
|
||||
assert 'c.metodo === "densa" || c.metodo === "manuale" ? c.metodo : "planare"' in PAGINA
|
||||
righe = PAGINA.split("function righe")[1].split("function tabella")[0]
|
||||
assert 'd.metodo === "manuale"' in righe and "Lato dello scacco" in righe # la manuale ha il suo elenco, le altre il solito
|
||||
assert 'v === null || v === undefined' in PAGINA.split("function fmt")[1].split("function fmtCasella")[0]
|
||||
stato = PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
|
||||
assert "Number(d.square_mm) > 0" in stato
|
||||
|
||||
|
||||
def test_pagina_calibrata_senza_raddrizzamento_dice_solo_scala():
|
||||
assert "solo scala, nessuna correzione ottica" in PAGINA
|
||||
assert 'r.headers.get("X-Raddrizzato") === "no"' in PAGINA
|
||||
@@ -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,186 @@
|
||||
"""Test della specchiatura orizzontale/verticale per sorgente (#110): 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
|
||||
Copre: GET/PUT /camera/orientamento, effetto su /camera/grab e /camera/anteprima,
|
||||
persistenza al riavvio, file rotto, validazione, scarto dei frame tenuti, avviso
|
||||
sulla calibrazione densa. Sorgente: camera:folder su un'immagine 4x3 asimmetrica.
|
||||
"""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_calibrazione import ( # calibrazione densa reale (#84), per l'avviso
|
||||
SORGENTE as SORGENTE_DENSA, cartella_densa, carica_su, client_denso, salva_con_nome,
|
||||
)
|
||||
|
||||
__all__ = ["cartella_densa", "client_denso"] # fixture importate, restano usate
|
||||
|
||||
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||
SORGENTE = "camera:folder"
|
||||
|
||||
|
||||
def _carica():
|
||||
spec = importlib.util.spec_from_file_location("camera_service_orient", MODULO)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella(tmp_path):
|
||||
# #110: 4x3 tutta nera con UN pixel acceso in alto a sinistra (riga 0, colonna 0)
|
||||
img = tmp_path / "foto"
|
||||
img.mkdir()
|
||||
f = np.zeros((3, 4, 3), np.uint8)
|
||||
f[0, 0] = 255
|
||||
cv2.imwrite(str(img / "a.png"), f)
|
||||
return img
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(tmp_path):
|
||||
return tmp_path / "cfg"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(cartella, config):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella, config)) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _acceso(png: bytes):
|
||||
"""(riga, colonna) del pixel acceso nel PNG; controlla che sia uno solo."""
|
||||
f = cv2.imdecode(np.frombuffer(png, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert f.shape[:2] == (3, 4)
|
||||
r, c = np.nonzero(f.max(axis=2) > 128)
|
||||
assert len(r) == 1
|
||||
return int(r[0]), int(c[0])
|
||||
|
||||
|
||||
def _imposta(c, orizz, vert, source=SORGENTE):
|
||||
return c.put("/camera/orientamento", json={"source": source, "orizzontale": orizz, "verticale": vert})
|
||||
|
||||
|
||||
def _grab(c):
|
||||
r = c.post("/camera/grab", json={"source": SORGENTE})
|
||||
assert r.status_code == 200, r.text
|
||||
return _acceso(r.content)
|
||||
|
||||
|
||||
def test_get_senza_file_false_false(client):
|
||||
r = client.get("/camera/orientamento", params={"source": SORGENTE})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"source": SORGENTE, "orizzontale": False, "verticale": False}
|
||||
|
||||
|
||||
def test_get_senza_source_400(client):
|
||||
assert client.get("/camera/orientamento").status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("orizz,vert,atteso", [
|
||||
(False, False, (0, 0)), # originale: alto a sinistra
|
||||
(True, False, (0, 3)), # orizzontale: alto a destra
|
||||
(False, True, (2, 0)), # verticale: basso a sinistra
|
||||
(True, True, (2, 3)), # entrambi: basso a destra (180 gradi)
|
||||
])
|
||||
def test_grab_specchiato(client, orizz, vert, atteso):
|
||||
r = _imposta(client, orizz, vert)
|
||||
assert r.status_code == 200, r.text
|
||||
assert (r.json()["orizzontale"], r.json()["verticale"]) == (orizz, vert)
|
||||
assert client.get("/camera/orientamento", params={"source": SORGENTE}).json()["orizzontale"] is orizz
|
||||
assert _grab(client) == atteso
|
||||
|
||||
|
||||
def test_tutti_false_toglie_la_sorgente_dal_file(client, config):
|
||||
_imposta(client, True, False)
|
||||
assert SORGENTE in (config / "orientamento.json").read_text(encoding="utf-8")
|
||||
_imposta(client, False, False)
|
||||
assert SORGENTE not in (config / "orientamento.json").read_text(encoding="utf-8")
|
||||
assert _grab(client) == (0, 0)
|
||||
|
||||
|
||||
def test_anteprima_specchiata_dimensioni_invariate(client):
|
||||
_imposta(client, True, True)
|
||||
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 4096})
|
||||
assert r.status_code == 200, r.text
|
||||
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("4", "3")
|
||||
f = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
|
||||
assert f.shape == (3, 4)
|
||||
# JPEG con perdite: il pixel acceso e' il piu' chiaro, in basso a destra
|
||||
assert np.unravel_index(int(np.argmax(f)), f.shape) == (2, 3)
|
||||
|
||||
|
||||
def test_persistenza_al_riavvio(cartella, config):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella, config)) as c1:
|
||||
assert _imposta(c1, True, False).status_code == 200
|
||||
with TestClient(_carica().create_app(cartella, config)) as c2:
|
||||
j = c2.get("/camera/orientamento", params={"source": SORGENTE}).json()
|
||||
assert (j["orizzontale"], j["verticale"]) == (True, False)
|
||||
assert _grab(c2) == (0, 3)
|
||||
|
||||
|
||||
def test_file_rotto_non_blocca_l_avvio(cartella, config):
|
||||
config.mkdir(parents=True)
|
||||
(config / "orientamento.json").write_text("{non e json", encoding="utf-8")
|
||||
with TestClient(_carica().create_app(cartella, config)) as c:
|
||||
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
|
||||
assert (j["orizzontale"], j["verticale"]) == (False, False)
|
||||
assert _imposta(c, False, True).status_code == 200 # e si puo' riscrivere
|
||||
|
||||
|
||||
def test_put_campi_non_bool_400(client):
|
||||
for corpo in ({"source": SORGENTE, "orizzontale": 1, "verticale": False},
|
||||
{"source": SORGENTE, "orizzontale": True, "verticale": "si"},
|
||||
{"source": SORGENTE, "orizzontale": True},
|
||||
{"orizzontale": True, "verticale": True}):
|
||||
assert client.put("/camera/orientamento", json=corpo).status_code == 400, corpo
|
||||
|
||||
|
||||
def test_put_scarta_i_frame_tenuti(client):
|
||||
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 200
|
||||
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 200
|
||||
_imposta(client, True, False)
|
||||
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 404
|
||||
# col frame tenuto scartato anche l'ultimo grezzo dell'anteprima e' sparito
|
||||
assert client.post("/camera/anteprima/calibrata", json={"source": SORGENTE}).status_code == 404
|
||||
|
||||
|
||||
def test_avviso_senza_mappa_e_null(client):
|
||||
j = _imposta(client, True, False).json()
|
||||
assert j["avviso"] is None
|
||||
|
||||
|
||||
def test_avviso_con_mappa_attiva(client_denso):
|
||||
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE_DENSA, "square_mm": "2"}).json()
|
||||
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
|
||||
assert carica_su(client_denso, SORGENTE_DENSA, "Densa").status_code == 200
|
||||
r = _imposta(client_denso, True, False, source=SORGENTE_DENSA)
|
||||
assert r.status_code == 200, r.text
|
||||
assert "«Densa»" in r.json()["avviso"] and "va rifatta" in r.json()["avviso"]
|
||||
# stesso valore di nuovo: nulla e' cambiato, nessun avviso
|
||||
assert _imposta(client_denso, True, False, source=SORGENTE_DENSA).json()["avviso"] is None
|
||||
|
||||
|
||||
def test_scrittura_fallita_500_e_orientamento_di_prima(cartella, config, monkeypatch):
|
||||
# #110: se orientamento.json non si scrive, in memoria resta l'orientamento di prima
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella, config)) as c:
|
||||
assert _imposta(c, True, False).status_code == 200 # stato di partenza: orizzontale
|
||||
def rotto(*a, **k):
|
||||
raise OSError("disco pieno")
|
||||
monkeypatch.setattr(mod.os, "replace", rotto)
|
||||
assert _imposta(c, False, True).status_code == 500
|
||||
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
|
||||
assert (j["orizzontale"], j["verticale"]) == (True, False)
|
||||
assert _grab(c) == (0, 3) # ancora solo orizzontale
|
||||
# sorgente che prima non c'era: la voce non resta
|
||||
assert c.put("/camera/orientamento", json={"source": "altra", "orizzontale": True, "verticale": True}).status_code == 500
|
||||
j = c.get("/camera/orientamento", params={"source": "altra"}).json()
|
||||
assert (j["orizzontale"], j["verticale"]) == (False, False)
|
||||
@@ -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,292 @@
|
||||
"""Test di #92: immagini caricate come sorgenti e anteprima calibrata (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
|
||||
Solo tmp_path: niente ProgramData, niente camera vera. Le immagini le scrive il test.
|
||||
"""
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
|
||||
CAMPI, _png, carica_su, cartella, client, salva_con_nome, una_scacchiera,
|
||||
una_scacchiera_densa,
|
||||
)
|
||||
|
||||
__all__ = ["cartella", "client"] # le fixture importate restano usate
|
||||
|
||||
DENSA = "densa.png"
|
||||
PIANA = "piana.png"
|
||||
|
||||
|
||||
def _modulo(c) -> dict:
|
||||
"""Il dict globale di camera_service (per sostituire open_source con una spia)."""
|
||||
return next(r.endpoint for r in c.app.routes if r.path == "/camera/grab").__globals__
|
||||
|
||||
|
||||
def _carica(c, nome: str, img: np.ndarray, **extra):
|
||||
"""POST /camera/immagini con un'immagine codificata PNG (o il contenuto dato)."""
|
||||
return c.post("/camera/immagini", files={"image": (nome, _png(img), "image/png")}, **extra)
|
||||
|
||||
|
||||
def _densa(**kw) -> np.ndarray:
|
||||
return una_scacchiera_densa(k=1e-7, **kw)
|
||||
|
||||
|
||||
def _calibra_e_attiva(c, nome_immagine: str, nome_calibrazione: str, **campi):
|
||||
"""Calcola dalla sorgente, salva con nome e la rende attiva su quella sorgente."""
|
||||
r = c.post("/camera/calibrate", data={"source": nome_immagine, "square_mm": "2", **campi})
|
||||
assert r.status_code == 200, r.text
|
||||
assert salva_con_nome(c, r.json(), nome_calibrazione).status_code == 201
|
||||
assert carica_su(c, nome_immagine, nome_calibrazione).status_code == 200
|
||||
|
||||
|
||||
def _anteprima(c, nome: str, **extra):
|
||||
return c.post("/camera/anteprima", json={"source": nome, "grezzo": True, **extra})
|
||||
|
||||
|
||||
def _calibrata(c, nome: str, **extra):
|
||||
return c.post("/camera/anteprima/calibrata", json={"source": nome, **extra})
|
||||
|
||||
|
||||
# ---- POST /camera/immagini
|
||||
|
||||
def test_carica_201_salva_in_cartella_immagini(client):
|
||||
r = _carica(client, "calibration_135_1.bmp", una_scacchiera())
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json() == {"source": "calibration_135_1.bmp",
|
||||
"label": "immagine — calibration_135_1.bmp", "kind": "image"}
|
||||
assert (client.config / "immagini" / "calibration_135_1.bmp").is_file()
|
||||
|
||||
|
||||
def test_carica_ripulisce_il_nome_e_toglie_il_percorso(client):
|
||||
r = _carica(client, "C:\\foto\\ban co è1.PNG", una_scacchiera())
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["source"] == "ban_co_1.PNG" # niente percorso, caratteri sicuri, estensione intatta
|
||||
assert (client.config / "immagini" / "ban_co_1.PNG").is_file()
|
||||
|
||||
|
||||
def test_carica_stesso_nome_sostituisce(client):
|
||||
_carica(client, "a.png", np.full((48, 64), 10, np.uint8))
|
||||
assert _carica(client, "a.png", np.full((30, 40), 200, np.uint8)).status_code == 201
|
||||
assert [f.name for f in (client.config / "immagini").iterdir()] == ["a.png"]
|
||||
img = cv2.imread(str(client.config / "immagini" / "a.png"), cv2.IMREAD_UNCHANGED)
|
||||
assert img.shape[:2] == (30, 40)
|
||||
|
||||
|
||||
def test_carica_senza_file_400(client):
|
||||
assert client.post("/camera/immagini").status_code == 400
|
||||
|
||||
|
||||
def test_carica_estensione_non_ammessa_400(client):
|
||||
r = client.post("/camera/immagini", files={"image": ("note.txt", b"ciao", "text/plain")})
|
||||
assert r.status_code == 400
|
||||
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
|
||||
|
||||
|
||||
def test_carica_file_illeggibile_422_e_niente_su_disco(client):
|
||||
r = client.post("/camera/immagini", files={"image": ("rotta.png", b"non e una immagine", "image/png")})
|
||||
assert r.status_code == 422
|
||||
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
|
||||
|
||||
|
||||
# ---- GET /camera/sources
|
||||
|
||||
def test_sources_con_immagini_e_senza_camera_file(client):
|
||||
_carica(client, "b.png", una_scacchiera())
|
||||
_carica(client, "a.png", una_scacchiera())
|
||||
voci = client.get("/camera/sources").json()["sources"]
|
||||
nomi = [v["name"] for v in voci]
|
||||
assert "camera:file" not in nomi
|
||||
assert "camera:folder" in nomi
|
||||
assert nomi[-2:] == ["a.png", "b.png"] # le immagini stanno in coda
|
||||
assert voci[-1] == {"name": "b.png", "kind": "image", "label": "immagine — b.png"}
|
||||
assert nomi.index("camera:folder") < nomi.index("a.png")
|
||||
|
||||
|
||||
def test_sources_camere_in_cache_e_aggiorna(client):
|
||||
# #92: la ricerca camere (~10 s) si fa una volta e resta 60 s; aggiorna=true la rilancia; le immagini sono sempre fresche
|
||||
g = _modulo(client)
|
||||
originale = g["list_sources"]
|
||||
chiamate = []
|
||||
|
||||
def finto(cartella, cameras=True, **kw):
|
||||
if cameras:
|
||||
chiamate.append(1)
|
||||
return originale(cartella, cameras=cameras, **kw)
|
||||
|
||||
g["list_sources"] = finto
|
||||
try:
|
||||
client.get("/camera/sources") # se la ricerca d'avvio e' ancora in corso aspetta il suo lock
|
||||
base = len(chiamate)
|
||||
_carica(client, "nuova.png", una_scacchiera())
|
||||
voci = client.get("/camera/sources").json()["sources"]
|
||||
client.get("/camera/sources")
|
||||
assert len(chiamate) == base # seconda e terza chiamata: nessuna nuova ricerca
|
||||
assert voci[-1]["name"] == "nuova.png" # ma l'immagine appena caricata c'e'
|
||||
client.get("/camera/sources", params={"aggiorna": "true"})
|
||||
assert len(chiamate) == base + 1 # aggiorna=true la rilancia
|
||||
client.get("/camera/sources")
|
||||
assert len(chiamate) == base + 1 # e il risultato nuovo torna in cache
|
||||
finally:
|
||||
g["list_sources"] = originale
|
||||
|
||||
|
||||
def test_sources_senza_immagini_non_crea_la_cartella(client):
|
||||
voci = client.get("/camera/sources").json()["sources"]
|
||||
assert all(v["kind"] != "image" for v in voci)
|
||||
|
||||
|
||||
# ---- grab / anteprima / calibrate su un'immagine caricata
|
||||
|
||||
def test_grab_anteprima_e_grab_ripetuti_su_immagine(client):
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
for _ in range(3): # l'anteprima continua non deve esaurire un'immagine sola
|
||||
r = client.post("/camera/grab", json={"source": "foto.png"})
|
||||
assert r.status_code == 200 and r.headers["content-type"] == "image/png"
|
||||
for _ in range(3):
|
||||
r = _anteprima(client, "foto.png")
|
||||
assert r.status_code == 200 and r.headers["content-type"] == "image/jpeg"
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("520", "400")
|
||||
|
||||
|
||||
def test_grab_immagine_inesistente_404(client):
|
||||
assert client.post("/camera/grab", json={"source": "non_c_e.png"}).status_code == 404
|
||||
|
||||
|
||||
def test_calibrate_su_immagine_origine_e_il_nome(client):
|
||||
_carica(client, "calibration_135_1.bmp", una_scacchiera())
|
||||
r = client.post("/camera/calibrate", data={"source": "calibration_135_1.bmp", **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["origine"] == "calibration_135_1.bmp"
|
||||
|
||||
|
||||
# ---- DELETE /camera/immagini
|
||||
|
||||
def test_elimina_204_poi_404_e_sparisce_dalle_sorgenti(client):
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||
assert not (client.config / "immagini" / "foto.png").exists()
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 404
|
||||
assert "foto.png" not in [v["name"] for v in client.get("/camera/sources").json()["sources"]]
|
||||
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 404
|
||||
|
||||
|
||||
def test_elimina_nome_con_percorso_404(client):
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
assert client.delete("/camera/immagini", params={"nome": "../immagini/foto.png"}).status_code == 404
|
||||
assert client.delete("/camera/immagini").status_code == 404
|
||||
assert (client.config / "immagini" / "foto.png").is_file()
|
||||
|
||||
|
||||
def test_elimina_chiude_la_sorgente_aperta(client, monkeypatch):
|
||||
chiuse = []
|
||||
modulo = _modulo(client)
|
||||
reale = modulo["open_source"]
|
||||
|
||||
def spia(*a, **k):
|
||||
s = reale(*a, **k)
|
||||
chiusura = s.close
|
||||
s.close = lambda: (chiuse.append(a[0]), chiusura())[1]
|
||||
return s
|
||||
|
||||
monkeypatch.setitem(modulo, "open_source", spia)
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 200
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||
assert chiuse == ["foto.png"]
|
||||
|
||||
|
||||
# ---- POST /camera/anteprima/calibrata
|
||||
|
||||
def test_calibrata_senza_frame_404(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
|
||||
|
||||
|
||||
def test_calibrata_senza_attiva_409(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 409 and r.json()["detail"] == "nessuna calibrazione attiva su questa sorgente"
|
||||
|
||||
|
||||
def test_calibrata_validazione_ingresso(client):
|
||||
assert client.post("/camera/anteprima/calibrata", json={}).status_code == 400
|
||||
assert _calibrata(client, DENSA, lato_max=100).status_code == 400
|
||||
assert _calibrata(client, DENSA, lato_max=True).status_code == 400
|
||||
|
||||
|
||||
def test_calibrata_200_densa_senza_nuova_lettura(client, monkeypatch):
|
||||
modulo = _modulo(client)
|
||||
reale = modulo["open_source"]
|
||||
letture = []
|
||||
|
||||
def conta(*a, **k):
|
||||
s = reale(*a, **k)
|
||||
originale = s.grab
|
||||
s.grab = lambda t=1000: (letture.append(1), originale(t))[1]
|
||||
return s
|
||||
|
||||
monkeypatch.setitem(modulo, "open_source", conta)
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco àè 1")
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
prima = len(letture)
|
||||
for _ in range(3):
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
assert r.headers["X-Raddrizzato"] == "si"
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480")
|
||||
assert unquote(r.headers["X-Calibrazione"]) == "Banco àè 1"
|
||||
assert r.headers["X-Calibrazione"] == quote("Banco àè 1", safe="") # percent-encoded UTF-8
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
assert img.shape[:2] == (480, 640)
|
||||
assert len(letture) == prima # la calibrata non legge mai un frame nuovo
|
||||
|
||||
|
||||
def test_calibrata_ridotta_a_lato_max(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco")
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
r = _calibrata(client, DENSA, lato_max=320)
|
||||
assert r.status_code == 200
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
assert max(img.shape[:2]) == 320
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480") # misure del frame pieno
|
||||
|
||||
|
||||
def test_anteprima_grezza_e_calibrata_usano_lo_stesso_frame_grezzo(client):
|
||||
# anche con anteprima NON grezza la copia tenuta e' quella prima di raddrizzare
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco")
|
||||
r = client.post("/camera/anteprima", json={"source": DENSA}) # calibrata: raddrizzata
|
||||
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si"
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si" # raddrizzata una volta sola
|
||||
|
||||
|
||||
def test_calibrata_risoluzione_diversa_409(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco")
|
||||
# stessa sorgente, immagine sostituita con un'altra risoluzione: la calibrazione resta attiva
|
||||
assert _carica(client, DENSA, _densa(w=320, h=240)).status_code == 201
|
||||
assert _calibrata(client, DENSA).status_code == 404 # sostituire l'immagine scorda il frame vecchio
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
|
||||
|
||||
|
||||
def test_calibrata_planare_senza_mappa_200_grezza(client):
|
||||
# #95: senza mappa (planare) non e' piu' 409: esce il frame grezzo, X-Raddrizzato: no
|
||||
_carica(client, PIANA, una_scacchiera())
|
||||
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
|
||||
assert _anteprima(client, PIANA).status_code == 200
|
||||
r = _calibrata(client, PIANA)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
|
||||
@@ -0,0 +1,87 @@
|
||||
"""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",
|
||||
"Attiva calibrazione"): # #92 #93
|
||||
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()]
|
||||
|
||||
|
||||
def test_specchiatura_per_sorgente(): # #110
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
assert {"specchiaO", "specchiaV", "msgSpecchia"} <= ids
|
||||
assert '"/camera/orientamento' in PAGINA
|
||||
assert "Specchia orizzontale" in PAGINA and "Specchia verticale" in PAGINA
|
||||
|
||||
|
||||
def test_zoom_visori(): # #111, #122: i visori sono <tmf-viewer>, la barra e lo zoom vengono dal componente
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
for i in ("immagine", "immagineCal", "immagineCalTab", "overlay"): # grezza, calibrata, tab Calibrazione, angoli
|
||||
assert re.search(r'<tmf-viewer id="%s"' % i, PAGINA), i
|
||||
assert PAGINA.count("<tmf-viewer") == 4
|
||||
for nome in ("grezza", "calibrata", "calibrazione", "angoli"):
|
||||
assert 'save-name="%s"' % nome in PAGINA, nome
|
||||
assert "zoomAdatta" not in PAGINA and "barraZoom" not in PAGINA and "creaVisore(" in PAGINA
|
||||
assert not {"zoomMeno", "zoomPiu", "zoomPct"} & ids
|
||||
assert "latoAnteprima()" in PAGINA and "unaAnteprima(1280)" not in PAGINA # lato_max segue lo zoom
|
||||
assert '"viewer-zoom"' in PAGINA # lo zoom si legge dall'evento del componente
|
||||
|
||||
|
||||
def test_script_del_visore_in_ordine(): # #122: i JS arrivano da /visore/ (offline), prima dello script di pagina
|
||||
src = re.findall(r'<script src="([^"]+)"', PAGINA)
|
||||
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
|
||||
assert PAGINA.index("/visore/image_viewer.js") < PAGINA.index("<script>")
|
||||
|
||||
|
||||
def test_nessuna_funzione_dichiarata_due_volte(): # #111: in JS vince l'ultima dichiarazione, in silenzio
|
||||
script = re.search(r"<script>(.*?)</script>", PAGINA, re.S).group(1)
|
||||
nomi = re.findall(r"function\s+([A-Za-z0-9_$]+)\s*\(", script)
|
||||
doppi = sorted({n for n in nomi if nomi.count(n) > 1})
|
||||
assert not doppi, "funzioni dichiarate piu' volte: " + ", ".join(doppi)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""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 "Attiva calibrazione" 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_non_sospeso_nella_tab_calibrazione():
|
||||
# #94: il ciclo non si ferma piu' entrando in Calibrazione (prima #92: A.riprendi); Calcola senza blocco aspetta le richieste in volo
|
||||
assert "A.riprendi" not in PAGINA
|
||||
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||
assert "fermaAnteprima();" in calcola and "A.inVolo) await pausa(50)" 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
|
||||
|
||||
|
||||
def test_elenco_sorgenti_senza_attese_e_calibrata_solo_se_serve():
|
||||
# #92: la voce dell'immagine caricata si aggiunge in pagina (niente rilettura dell'elenco); «Aggiorna» forza la ricerca camere
|
||||
assert "aggiungiSorgente(j);" in PAGINA and "caricaSorgenti(j.source)" not in PAGINA
|
||||
assert 'id="aggiornaSorgenti"' in PAGINA and ">Aggiorna</button>" in PAGINA
|
||||
assert '"?aggiorna=true"' in PAGINA
|
||||
# nessuna calibrazione attiva: il riquadro lo dice senza chiamare il servizio; dopo un 409 non si richiede piu'
|
||||
assert "if (A.calAttiva === false)" in PAGINA
|
||||
assert "nessuna calibrazione attiva su questa sorgente" in PAGINA
|
||||
assert "if (r.status === 409) A.bloccoCal = true;" in PAGINA and "if (A.bloccoCal) return;" in PAGINA
|
||||
assert PAGINA.count("A.bloccoCal = false;") >= 3 # cambio sorgente, Carica, Scollega
|
||||
|
||||
|
||||
def test_attiva_calibrazione_con_scelta_e_conferma():
|
||||
# #93: sotto la calibrata una combo e CONFERMA; la casella cambia nome
|
||||
for i in ('id="sceltaCal"', 'id="confermaCal"', 'id="msgSceltaCal"'):
|
||||
assert i in PAGINA, i
|
||||
assert "Attiva calibrazione" in PAGINA and ">CONFERMA</button>" in PAGINA
|
||||
assert "Mostra immagine calibrata" not in PAGINA
|
||||
assert PAGINA.index('id="riquadroCal"') < PAGINA.index('id="sceltaCal"') < PAGINA.index('id="confermaCal"') < PAGINA.index('id="msgSceltaCal"')
|
||||
# il gestore di CONFERMA usa PUT calibrazione-attiva (via caricaNome) o DELETE calibration (via scollega), messaggio sotto la calibrata
|
||||
gestore = PAGINA[PAGINA.index('$("confermaCal").onclick'):]
|
||||
gestore = gestore[:gestore.index("};")]
|
||||
assert 'caricaNome(nome, "msgSceltaCal")' in gestore and 'scollega("msgSceltaCal")' in gestore
|
||||
assert 'method: "PUT"' in PAGINA and "/camera/calibrazione-attiva" in PAGINA
|
||||
assert 'method: "DELETE"' in PAGINA and '"/camera/calibration?source="' in PAGINA
|
||||
# voce vuota «nessuna», suffisso di risoluzione, combo ridisegnata con l'elenco e spenta senza sorgente
|
||||
assert "— nessuna (non calibrata) —" in PAGINA and "— risoluzione diversa" in PAGINA
|
||||
assert "disegnaSceltaCal();" in PAGINA and "= !$(\"sorgente\").value" in PAGINA
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Test delle rotte /visore/*.js di camera_service e della pagina che le usa (#122).
|
||||
|
||||
Uso: python -m pytest scripts/tests/test_visore_rotte.py -q
|
||||
Dipende da: venv con fastapi, httpx, opencv (come test_camera_service.py).
|
||||
image_viewer.js lo scrive un altro piano (V1): se manca nei sorgenti il test della rotta usa una cartella temporanea con file finti.
|
||||
"""
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||
NOMI = ("image_viewer.js", "save_image.js", "vision_overlay.js")
|
||||
|
||||
|
||||
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 client(tmp_path):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(tmp_path)) as c:
|
||||
c.mod = mod
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella_finta(client, tmp_path):
|
||||
# file finti in una cartella temporanea: la rotta si prova senza dipendere da image_viewer.js del frontend
|
||||
d = tmp_path / "js"
|
||||
d.mkdir()
|
||||
for n in NOMI:
|
||||
(d / n).write_text("/* finto " + n + " */\n", encoding="utf-8")
|
||||
client.mod._CARTELLE_VISORE[:] = [d]
|
||||
return d
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nome", NOMI)
|
||||
def test_rotta_serve_il_file(client, cartella_finta, nome):
|
||||
r = client.get("/visore/" + nome)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("application/javascript")
|
||||
assert r.content == (cartella_finta / nome).read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nome", ["altro.js", "camera_service.py", "..%2Fcamera_service.py", "image_viewer.JS", "image_viewer.js.map"])
|
||||
def test_altri_nomi_danno_404(client, cartella_finta, nome):
|
||||
assert client.get("/visore/" + nome).status_code == 404
|
||||
|
||||
|
||||
def test_file_mancante_nella_cartella_da_404(client, tmp_path):
|
||||
client.mod._CARTELLE_VISORE[:] = [tmp_path / "vuota"]
|
||||
assert client.get("/visore/save_image.js").status_code == 404
|
||||
|
||||
|
||||
def test_cartelle_di_default_sono_static_js_del_frontend(client):
|
||||
# sviluppo: sorgenti del frontend; pacchetto Nuitka: static/ accanto al modulo (build.ps1 la include)
|
||||
cartelle = _carica()._CARTELLE_VISORE
|
||||
assert any(c.parts[-5:] == ("src", "frontend", "flask_app", "static", "js") for c in cartelle)
|
||||
assert any(c.parts[-2:] == ("static", "js") and c.parent.parent == MODULO.parent for c in cartelle)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nome", ("save_image.js", "vision_overlay.js", "image_viewer.js"))
|
||||
def test_file_reali_del_frontend(client, nome):
|
||||
reale = next((c / nome for c in client.mod._CARTELLE_VISORE if (c / nome).is_file()), None)
|
||||
if reale is None:
|
||||
pytest.skip(nome + " non e' ancora nei sorgenti del frontend (lo scrive il piano V1)")
|
||||
r = client.get("/visore/" + nome)
|
||||
assert r.status_code == 200 and r.content == reale.read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rotta", ("/acquisizione", "/calibrazione"))
|
||||
def test_pagina_usa_quattro_visori_e_i_tre_script(client, rotta):
|
||||
html = client.get(rotta).text
|
||||
assert html.count("<tmf-viewer") == 4
|
||||
src = re.findall(r'<script src="(/visore/[^"]+)"', html)
|
||||
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
|
||||
assert "zoomAdatta" not in html
|
||||
@@ -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",
|
||||
@@ -327,9 +357,13 @@ async def export_measurements_csv(
|
||||
# Rows
|
||||
for m in measurements:
|
||||
# Format decimals with configured separator
|
||||
value_str = str(m.value).replace(".", decimal_separator)
|
||||
# #118: quota non misurata dalla visione -> NaN
|
||||
value_str = "NaN" if m.value is None else str(m.value).replace(".", decimal_separator)
|
||||
# #124: quota NaN (value None) -> deviazione NaN, non vuota
|
||||
deviation_str = (
|
||||
str(m.deviation).replace(".", decimal_separator)
|
||||
"NaN"
|
||||
if m.value is None
|
||||
else str(m.deviation).replace(".", decimal_separator)
|
||||
if m.deviation is not None
|
||||
else ""
|
||||
)
|
||||
@@ -342,10 +376,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 +389,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,8 +144,10 @@ 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]
|
||||
# #118: le quote NaN (value None) non entrano nei calcoli
|
||||
values = [float(m.value) for m in measurements if m.value is not None]
|
||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||
return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"])
|
||||
|
||||
@@ -145,11 +157,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,7 +170,10 @@ 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
|
||||
)
|
||||
# #118: le quote NaN (value None) si saltano, valori e istanti restano allineati
|
||||
measurements = [m for m in measurements if m.value is not None]
|
||||
values = [float(m.value) for m in measurements]
|
||||
timestamps = [m.measured_at for m in measurements]
|
||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||
@@ -172,11 +188,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,8 +202,10 @@ 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]
|
||||
# #118: le quote NaN (value None) non entrano nell'istogramma
|
||||
values = [float(m.value) for m in measurements if m.value is not None]
|
||||
return compute_histogram(values, n_bins)
|
||||
|
||||
|
||||
@@ -234,3 +253,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,12 @@ async def create_task(
|
||||
file_type=data.file_type,
|
||||
annotations_json=data.annotations_json,
|
||||
vision_json=data.vision_json,
|
||||
# #97: camera e modo di analisi (dict gia' validato dal modello)
|
||||
acquisition_json=data.acquisition_json.model_dump() if data.acquisition_json else None,
|
||||
# #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 +340,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 +357,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 +390,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 +435,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 +457,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 +480,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 +498,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 +527,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 +541,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,33 @@ 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
|
||||
import mimetypes
|
||||
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 (
|
||||
VisionCheckResponse, 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"])
|
||||
|
||||
@@ -26,8 +41,39 @@ _IMAGE_EXTENSIONS = {
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/bmp": "bmp", # #99
|
||||
"image/x-ms-bmp": "bmp", # #99
|
||||
}
|
||||
|
||||
# #99: the Opto bench cameras save BMP and the worker decodes it (PIL), so the
|
||||
# vision routes accept it on top of files.py's list. files.py is untouched: its
|
||||
# attachments are served by extension and BMP is not meant to be shown there.
|
||||
_VISION_IMAGE_TYPES = ALLOWED_IMAGE_TYPES | {"image/bmp", "image/x-ms-bmp"}
|
||||
|
||||
# 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). TIFF is
|
||||
# included even though the allow-list does not accept it (PIL, and so the
|
||||
# worker, can decode it; browsers do not show it in the result); BMP is now
|
||||
# also allowed (#99). 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."""
|
||||
@@ -46,7 +92,7 @@ async def _read_image(image: UploadFile) -> bytes:
|
||||
is in hand - the same two-step convention `files.py::upload_file` already
|
||||
uses, reused rather than reinvented here.
|
||||
"""
|
||||
if image.content_type not in ALLOWED_IMAGE_TYPES:
|
||||
if image.content_type not in _VISION_IMAGE_TYPES: # #99
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
||||
@@ -60,22 +106,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 +153,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()
|
||||
|
||||
@@ -92,15 +162,151 @@ async def execute(
|
||||
engine_version=result.engine_version,
|
||||
measurements=[
|
||||
VisionMeasurementResult(
|
||||
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
|
||||
subtask_id=m.subtask_id,
|
||||
# #118: None = quota non prodotta, il frontend la mostra NaN
|
||||
value=float(m.value) if m.value is not None else None,
|
||||
pass_fail=m.pass_fail,
|
||||
)
|
||||
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.post("/check", response_model=VisionCheckResponse)
|
||||
async def check(
|
||||
task_id: int = Form(...),
|
||||
image: UploadFile = File(...),
|
||||
production_run_id: int | None = Form(None, gt=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_measurement_tec),
|
||||
) -> VisionCheckResponse:
|
||||
"""#97: analisi di un frame senza salvare nulla (ne' risultato ne' misure)."""
|
||||
task = await _task_or_404(db, task_id)
|
||||
report = await vision_service.check_task(
|
||||
db, task, await _read_image(image), production_run_id=production_run_id,
|
||||
)
|
||||
return VisionCheckResponse(**report)
|
||||
|
||||
|
||||
@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 +324,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 +368,133 @@ 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("/dxf-preview")
|
||||
async def dxf_preview(
|
||||
dxf: UploadFile = File(...),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> dict:
|
||||
"""#101: anteprima del DXF scelto nel wizard «Crea ricetta da DXF» (contorni
|
||||
in mm e dimensioni), letto dal worker con lo stesso lettore del confronto.
|
||||
Non scrive nulla; stesso limite di dimensione della rotta dxf-compare."""
|
||||
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"])
|
||||
return await vision_service.dxf_preview(dxf_bytes)
|
||||
|
||||
|
||||
@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)
|
||||
# #105: l'immagine di riferimento resta nella cartella (non e' in meta, quindi
|
||||
# /validate e l'esecuzione la ignorano) e il wizard puo' riaprirla; entra nel digest.
|
||||
placed[f"riferimento.{_IMAGE_EXTENSIONS.get(image.content_type, 'png')}"] = image_bytes
|
||||
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
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/recipe/dxf-compare/source")
|
||||
async def get_dxf_compare_source(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> dict:
|
||||
"""#105: i parametri della ricetta fatta con «Crea ricetta da DXF», per modificarla.
|
||||
|
||||
404 se il task non ha una ricetta del wizard (nessuna, o caricata a mano).
|
||||
"""
|
||||
task = await _task_or_404(db, task_id)
|
||||
source = vision_service.dxf_compare_source(task.vision_json, task.vision_recipe_dir)
|
||||
if source is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="nessuna ricetta da DXF su questo task",
|
||||
)
|
||||
return source
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/recipe/files/{path:path}")
|
||||
async def get_recipe_file(
|
||||
task_id: int,
|
||||
path: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> FileResponse:
|
||||
"""#105: un file della cartella ricetta del task (DXF, immagine di riferimento).
|
||||
|
||||
Percorso non sicuro, file assente o task senza ricetta: sempre 404.
|
||||
"""
|
||||
task = await _task_or_404(db, task_id)
|
||||
target = vision_service.recipe_file_path(task.vision_recipe_dir, path)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||
media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||
return FileResponse(target, media_type=media_type)
|
||||
|
||||
+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')
|
||||
@@ -0,0 +1,31 @@
|
||||
"""recipe_tasks.acquisition_json (#97)
|
||||
|
||||
recipe_tasks.acquisition_json: camera e modo di analisi scelti nel task,
|
||||
{"camera_url", "camera_source", "modo": "impulsiva" | "in_linea"}; NULL = la
|
||||
camera della stazione dell'operatore, analisi impulsiva (come prima).
|
||||
|
||||
Revision ID: 021_task_acquisition
|
||||
Revises: 020_station_camera
|
||||
Create Date: 2026-10-24
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '021_task_acquisition'
|
||||
down_revision: Union[str, None] = '020_station_camera'
|
||||
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('recipe_tasks') as batch:
|
||||
batch.add_column(sa.Column('acquisition_json', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.drop_column('acquisition_json')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""measurements.value nullable (#118)
|
||||
|
||||
measurements.value: NULL = quota che il grafo di visione non ha prodotto
|
||||
(l'operatore la vede come NaN, esito sempre `fail`, deviazione NULL). Prima
|
||||
execute rifiutava con 422 l'intera acquisizione. measurements.deviation era
|
||||
gia' nullable.
|
||||
|
||||
Downgrade: torna NOT NULL; FALLISCE se esistono righe con value NULL (vanno
|
||||
cancellate o valorizzate prima).
|
||||
|
||||
Revision ID: 022_measurement_value_nullable
|
||||
Revises: 021_task_acquisition
|
||||
Create Date: 2026-10-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '022_measurement_value_nullable'
|
||||
down_revision: Union[str, None] = '021_task_acquisition'
|
||||
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('measurements') as batch:
|
||||
batch.alter_column(
|
||||
'value', existing_type=sa.DECIMAL(12, 6), nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('measurements') as batch:
|
||||
batch.alter_column(
|
||||
'value', existing_type=sa.DECIMAL(12, 6), nullable=False,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -36,7 +43,7 @@ class MeasurementResponse(BaseModel):
|
||||
subtask_id: int
|
||||
version_id: int
|
||||
measured_by: int
|
||||
value: float
|
||||
value: Optional[float] = None # #118: null = quota non prodotta dalla visione (NaN)
|
||||
pass_fail: str
|
||||
deviation: Optional[float] = None
|
||||
lot_number: Optional[str] = None
|
||||
@@ -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: Optional[float] = None # #118: null = NaN
|
||||
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: Optional[float] = None # #118: None = solo quote NaN
|
||||
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,111 @@
|
||||
"""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, field_validator, model_validator
|
||||
|
||||
from src.backend.models.orm.task import TASK_TYPES
|
||||
from src.backend.models.api.station import _normalize_http_url
|
||||
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 +120,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."""
|
||||
@@ -58,6 +160,30 @@ class SubtaskResponse(BaseModel):
|
||||
vision_output: Optional[str] = None
|
||||
|
||||
|
||||
class AcquisitionConfig(BaseModel):
|
||||
"""#97: camera (servizio + sorgente) e modo di analisi scelti nel task."""
|
||||
camera_url: str = Field(..., max_length=500)
|
||||
camera_source: str = Field(..., min_length=1, max_length=200)
|
||||
modo: Literal["impulsiva", "in_linea"] = "impulsiva"
|
||||
|
||||
@field_validator("camera_url")
|
||||
@classmethod
|
||||
def _check_camera_url(cls, value: str) -> str:
|
||||
# stessa normalizzazione delle stazioni; qui il vuoto non e' ammesso
|
||||
normalized = _normalize_http_url(value, "camera_url")
|
||||
if normalized is None:
|
||||
raise ValueError("camera_url must not be empty")
|
||||
return normalized
|
||||
|
||||
@field_validator("camera_source")
|
||||
@classmethod
|
||||
def _check_camera_source(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("camera_source must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
"""Schema for creating a task."""
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
@@ -68,6 +194,11 @@ class TaskCreate(BaseModel):
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||
# #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 +212,10 @@ class TaskUpdate(BaseModel):
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||
in_loop: Optional[bool] = None
|
||||
at_close: Optional[bool] = None
|
||||
fields: Optional[list[dict[str, Any]]] = None
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
@@ -99,6 +234,10 @@ class TaskResponse(BaseModel):
|
||||
file_type: Optional[str] = None
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||
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,11 +1,13 @@
|
||||
"""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):
|
||||
"""One quote's outcome, as much of it as the client needs to show."""
|
||||
subtask_id: int
|
||||
value: float
|
||||
value: float | None # #118: null = quota non prodotta dal grafo (NaN), pass_fail "fail"
|
||||
pass_fail: str
|
||||
|
||||
|
||||
@@ -14,3 +16,54 @@ 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 VisionCheckResponse(BaseModel):
|
||||
"""What `POST /api/vision/check` returns (#97): the analysis, nothing saved."""
|
||||
outcome: str | None = None
|
||||
overlay: list[dict] = []
|
||||
failures: list[dict] = []
|
||||
measurements: list[VisionMeasurementResult] = []
|
||||
# esito della lettura della calibrazione: ok | no_camera | not_calibrated | unreachable
|
||||
calibration: str
|
||||
|
||||
|
||||
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)
|
||||
@@ -26,7 +26,8 @@ class Measurement(Base):
|
||||
)
|
||||
|
||||
# Measurement data
|
||||
value: Mapped[float] = mapped_column(DECIMAL(12, 6), nullable=False)
|
||||
# #118: NULL = quota che il grafo di visione non ha prodotto (mostrata NaN, sempre fail)
|
||||
value: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True)
|
||||
pass_fail: Mapped[str] = mapped_column(
|
||||
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
|
||||
)
|
||||
|
||||
@@ -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,23 @@ 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)
|
||||
# #97: camera e modo di analisi del task, {camera_url, camera_source, modo};
|
||||
# NULL = camera della stazione corrente, analisi impulsiva.
|
||||
acquisition_json: Mapped[Optional[dict]] = mapped_column(JSON, 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) if m.value is not None else None, # #118: NaN
|
||||
"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
|
||||
@@ -218,7 +355,7 @@ async def save_measurement(
|
||||
subtask_id: int,
|
||||
version_id: int,
|
||||
measured_by: int,
|
||||
value: float,
|
||||
value: float | None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
input_method: str = "manual",
|
||||
@@ -235,7 +372,13 @@ async def save_measurement(
|
||||
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
|
||||
the gate models "moving on" between measurements taken one after another,
|
||||
which does not exist inside a single camera acquisition.
|
||||
|
||||
#118: ``value=None`` is a quote the vision graph did not produce (shown as
|
||||
NaN): saved as a ``fail`` with no deviation. Only the vision path passes it.
|
||||
"""
|
||||
if value is not None: # #118: una quota non misurata non ha un valore da validare
|
||||
_validate_measured_value(value)
|
||||
|
||||
# Get subtask for tolerance values
|
||||
result = await db.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||
@@ -244,6 +387,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,7 +422,15 @@ async def save_measurement(
|
||||
),
|
||||
)
|
||||
|
||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||
# 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.
|
||||
if value is None:
|
||||
# #118: quota non prodotta dal grafo -> NaN: fail, nessuna deviazione
|
||||
pass_fail, deviation = "fail", None
|
||||
else:
|
||||
value = round(value, 6)
|
||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||
|
||||
measurement = Measurement(
|
||||
subtask_id=subtask_id,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""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()):
|
||||
# #118: le quote NaN (value None) non entrano nella media; con soli
|
||||
# NaN la quota resta in elenco con mean None ed e' fuori tolleranza
|
||||
numeric = [float(g.value) for g in group if g.value is not None]
|
||||
mean = sum(numeric) / len(numeric) if numeric else None
|
||||
first = group[0]
|
||||
subtasks.append(MonitorSubtask(
|
||||
subtask_id=sid, description=first.description, unit=first.unit,
|
||||
count=len(group),
|
||||
mean=round(mean, 6) if mean is not None else None, # #118
|
||||
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)
|
||||
if mean is not None else False # #118: tutte NaN = fail
|
||||
),
|
||||
))
|
||||
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,18 +149,18 @@ 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),
|
||||
number(measurement.value) if measurement.value is not None else "NaN", # #118
|
||||
"NaN" if measurement.value is None else number(measurement.deviation), # #124: quota NaN -> deviazione NaN
|
||||
measurement.pass_fail,
|
||||
number(subtask.ltl) if subtask else "",
|
||||
number(subtask.lwl) if subtask else "",
|
||||
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user