Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f20b92bafb | |||
| 6bfe3a5d1a | |||
| 5717b3e7aa | |||
| 85d7976f4e | |||
| a72e3f11af | |||
| 46566ebfa5 | |||
| 0bd95367bb | |||
| 38ef0587f5 | |||
| 533edc5be8 | |||
| 3870cc3b0f | |||
| 398af016f8 | |||
| b86eeada86 | |||
| ddf7788d77 | |||
| 05f2937611 | |||
| 71da162e1f | |||
| 9387e7c306 | |||
| 0a3d1f5222 | |||
| c5f3366dd1 | |||
| 9546ded1e8 | |||
| b578ac9e1b | |||
| cb0fab8c92 |
@@ -3,6 +3,8 @@
|
|||||||
# ===========================
|
# ===========================
|
||||||
|
|
||||||
# --- Database ---
|
# --- 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_HOST=localhost
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=tiemeasureflow
|
DB_NAME=tiemeasureflow
|
||||||
@@ -10,12 +12,20 @@ DB_USER=tmflow
|
|||||||
DB_PASSWORD=change_me_in_production
|
DB_PASSWORD=change_me_in_production
|
||||||
|
|
||||||
# --- Server ---
|
# --- 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_HOST=0.0.0.0
|
||||||
SERVER_PORT=8000
|
SERVER_PORT=8000
|
||||||
SERVER_SECRET_KEY=change-this-to-a-random-secret-key
|
SERVER_SECRET_KEY=change-this-to-a-random-secret-key
|
||||||
SERVER_CORS_ORIGINS=http://localhost:5000
|
SERVER_CORS_ORIGINS=http://localhost:5000
|
||||||
|
|
||||||
# --- Client ---
|
# --- 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_HOST=0.0.0.0
|
||||||
CLIENT_PORT=5000
|
CLIENT_PORT=5000
|
||||||
CLIENT_SECRET_KEY=change-this-to-another-random-secret-key
|
CLIENT_SECRET_KEY=change-this-to-another-random-secret-key
|
||||||
@@ -29,6 +39,20 @@ STATION_CODE=ST-DEFAULT
|
|||||||
# floor the station identity comes from the local install, and measuring against
|
# floor the station identity comes from the local install, and measuring against
|
||||||
# another station's recipes would silently break traceability.
|
# another station's recipes would silently break traceability.
|
||||||
STATION_SWITCH_ENABLED=0
|
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.
|
||||||
|
VISION_WORKER_URL=http://vision:8100
|
||||||
|
# The pinned VisionSuite commit, stamped into the vision image at build time
|
||||||
|
# (Dockerfile.vision ARG). Required: `docker compose build vision` fails
|
||||||
|
# immediately without it. Set with:
|
||||||
|
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD)
|
||||||
|
VISION_ENGINE_VERSION=
|
||||||
|
|
||||||
# --- File Storage ---
|
# --- File Storage ---
|
||||||
# Resolved against the project root in src/backend/config.py.
|
# Resolved against the project root in src/backend/config.py.
|
||||||
@@ -39,12 +63,40 @@ MAX_UPLOAD_SIZE_MB=50
|
|||||||
|
|
||||||
# --- Setup Page ---
|
# --- Setup Page ---
|
||||||
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
|
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) ---
|
# --- AI (OpenRouter) ---
|
||||||
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
|
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
|
||||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare
|
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 ---
|
# --- Docker ---
|
||||||
|
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
|
||||||
DB_ROOT_PASSWORD=root_password_change_me
|
DB_ROOT_PASSWORD=root_password_change_me
|
||||||
NGINX_PORT=80
|
NGINX_PORT=80
|
||||||
NGINX_SSL_PORT=443
|
NGINX_SSL_PORT=443
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# TieMeasureFlow#81: static del frontend sempre con LF, anche con core.autocrlf=true su Windows:
|
||||||
|
# le impronte sha256 di static/vendor/VERSIONS.md (test_offline.py) e il pacchetto Windows le vogliono identiche.
|
||||||
|
src/frontend/flask_app/static/** text=auto eol=lf
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# TieMeasureFlow#76: setup.exe di TieMeasureFlow per Windows, generato al tag v*.
|
||||||
|
# Cosa fa: submodule vendor/visionsuite (deploy key), build.ps1 (uv sync Python 3.13, Tailwind,
|
||||||
|
# Nuitka --standalone, migrazioni .pyc, DLL di WeasyPrint, Inno Setup), poi il setup come
|
||||||
|
# artifact e come asset della release Gitea del tag.
|
||||||
|
# Da cosa dipende: un runner Gitea con label `windows` che giri su un host Windows (non in
|
||||||
|
# container) con Visual Studio/Build Tools (MSVC), git e node (serve a actions/checkout e a
|
||||||
|
# Tailwind); uv e Inno Setup li installa il workflow se mancano. Secret SUBMODULI_SSH_KEY
|
||||||
|
# (deploy key di sola lettura di TieVision-Core, impostato sul repo il 17/10/2026).
|
||||||
|
# Il runner `windows` e' act_runner in modalita' host su daitarn (capacity 1).
|
||||||
|
# ⚠️ upload-artifact@v3: e' la versione che usa gia' vs-native.yml in TieVision-Core su questo
|
||||||
|
# Gitea; la v4 usa un protocollo che act_runner puo' non supportare.
|
||||||
|
# ⚠️ L'asset della release usa il token del job (secrets.GITHUB_TOKEN): se la chiamata risponde
|
||||||
|
# 403 serve un token personale in un secret (es. RILASCI_TOKEN) al posto di GITHUB_TOKEN.
|
||||||
|
# ⚠️ La build da freddo e' lunga (Nuitka compila backend, visione, plotly, opencv...): timeout 180 min.
|
||||||
|
name: rilascio-windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
setup-windows:
|
||||||
|
runs-on: windows
|
||||||
|
timeout-minutes: 180
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: powershell
|
||||||
|
steps:
|
||||||
|
# Checkout senza ssh-key (come ci.yml): i submodule si inizializzano a parte, con la deploy key.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Submodule privati (deploy key in sola lettura)
|
||||||
|
env:
|
||||||
|
SUBMODULI_SSH_KEY: ${{ secrets.SUBMODULI_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
# cartella temporanea del job, non ~/.ssh: su un runner host e' l'account dell'utente
|
||||||
|
$ssh = Join-Path $env:TEMP ("submoduli-" + [guid]::NewGuid().ToString('N'))
|
||||||
|
New-Item -ItemType Directory -Force $ssh | Out-Null
|
||||||
|
$chiave = Join-Path $ssh 'chiave'
|
||||||
|
# LF e newline finale: OpenSSH rifiuta una chiave con CRLF
|
||||||
|
[System.IO.File]::WriteAllText($chiave, ($env:SUBMODULI_SSH_KEY.Trim() + "`n"), (New-Object System.Text.UTF8Encoding $false))
|
||||||
|
# OpenSSH su Windows rifiuta una chiave leggibile da altri utenti; (F) e non (R):
|
||||||
|
# con la sola lettura Remove-Item finale non puo' cancellarla
|
||||||
|
icacls $chiave /inheritance:r | Out-Null
|
||||||
|
icacls $chiave /grant:r "$($env:USERNAME):(F)" | Out-Null
|
||||||
|
$known = Join-Path $ssh 'known_hosts'
|
||||||
|
[System.IO.File]::WriteAllText($known, "[git.tielogic.xyz]:222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDvABW6Tvruqkf7KJhJZUiAX6NeCx0enwcNXzZbECQ8J`n", (New-Object System.Text.UTF8Encoding $false))
|
||||||
|
$env:GIT_SSH_COMMAND = "ssh -i $($chiave -replace '\\','/') -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$($known -replace '\\','/')"
|
||||||
|
try {
|
||||||
|
git submodule update --init --recursive
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git submodule update fallito" }
|
||||||
|
} finally {
|
||||||
|
# la deploy key non resta mai sul disco del runner
|
||||||
|
Remove-Item $ssh -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: uv (se manca)
|
||||||
|
run: |
|
||||||
|
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||||
|
irm https://astral.sh/uv/install.ps1 | iex
|
||||||
|
}
|
||||||
|
# il PATH del passo successivo
|
||||||
|
"$env:USERPROFILE\.local\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Node (se manca)
|
||||||
|
run: |
|
||||||
|
# serve a Tailwind (npx) in build.ps1
|
||||||
|
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||||
|
winget install --id OpenJS.NodeJS.LTS -e --silent --accept-package-agreements --accept-source-agreements
|
||||||
|
}
|
||||||
|
"$env:ProgramFiles\nodejs" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Inno Setup (se manca)
|
||||||
|
run: |
|
||||||
|
$trovato = (Get-Command ISCC.exe -ErrorAction SilentlyContinue) -or
|
||||||
|
(Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe") -or
|
||||||
|
(Test-Path "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")
|
||||||
|
if (-not $trovato) {
|
||||||
|
winget install --id JRSoftware.InnoSetup -e --silent --accept-package-agreements --accept-source-agreements
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Build (Nuitka + Inno Setup)
|
||||||
|
# GITHUB_REF_NAME e' il tag (v0.1.0); con workflow_dispatch e' il ramo: build.ps1 rifiuta
|
||||||
|
# un nome senza numeri, quindi in quel caso si passa 0.0.0
|
||||||
|
run: |
|
||||||
|
$v = '${{ github.ref_name }}'
|
||||||
|
if ($v -notmatch '^v?\d+\.\d+\.\d+') { $v = '0.0.0' }
|
||||||
|
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione $v
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "build.ps1 fallito" }
|
||||||
|
|
||||||
|
- name: Setup come artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: TieMeasureFlow-setup
|
||||||
|
path: dist/TieMeasureFlow-setup-*.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Asset della release Gitea (solo ai tag)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$h = @{ Authorization = "token $env:TOKEN" }
|
||||||
|
# la release del tag: la crea il primo job che arriva, poi si riusa
|
||||||
|
try {
|
||||||
|
$rel = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG"
|
||||||
|
} catch {
|
||||||
|
$corpo = @{ tag_name = $env:TAG; name = "TieMeasureFlow $env:TAG" } | ConvertTo-Json
|
||||||
|
$rel = Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/json' -Body $corpo "$env:API/releases"
|
||||||
|
}
|
||||||
|
$setup = Get-ChildItem dist\TieMeasureFlow-setup-*.exe | Select-Object -First 1
|
||||||
|
# curl.exe (incluso in Windows 10+) fa il multipart senza codice a mano
|
||||||
|
curl.exe --fail -sS -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@$($setup.FullName)" "$env:API/releases/$($rel.id)/assets?name=$($setup.Name)"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "caricamento dell'asset fallito" }
|
||||||
+9
-13
@@ -33,19 +33,12 @@ env/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
desktop.ini
|
desktop.ini
|
||||||
|
|
||||||
# Uploads (server-side files, now at project root)
|
# Uploads (server-side files, mounted as a volume in production): ignora tutto
|
||||||
uploads/images/*
|
# sotto uploads/, comprese le sottocartelle create a runtime (vision reference
|
||||||
uploads/pdfs/*
|
# images, cartelle per ricetta/versione) — tieni solo i .gitkeep che mantengono
|
||||||
uploads/logos/*
|
# le cartelle vuote in git.
|
||||||
uploads/reports/*
|
uploads/*
|
||||||
uploads/general/
|
!uploads/**/.gitkeep
|
||||||
# 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
|
|
||||||
|
|
||||||
# TailwindCSS output
|
# TailwindCSS output
|
||||||
src/frontend/flask_app/static/css/tailwind.css
|
src/frontend/flask_app/static/css/tailwind.css
|
||||||
@@ -77,3 +70,6 @@ nul
|
|||||||
# Competitor analysis (local only)
|
# Competitor analysis (local only)
|
||||||
Concorrente/
|
Concorrente/
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Subagent-driven development scratch: ledger, briefs, review packages
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vendor/visionsuite"]
|
||||||
|
path = vendor/visionsuite
|
||||||
|
url = ssh://git@git.tielogic.xyz:222/Adriano/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.
|
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.
|
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/
|
TieMeasureFlow/
|
||||||
@@ -18,10 +18,13 @@ TieMeasureFlow/
|
|||||||
├── .python-version # 3.11
|
├── .python-version # 3.11
|
||||||
├── Dockerfile # Backend (uv + uvicorn)
|
├── Dockerfile # Backend (uv + uvicorn)
|
||||||
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||||
|
├── Dockerfile.vision # Worker di visione (uv, Python 3.13)
|
||||||
├── docker-compose.dev.yml # Dev (Nginx)
|
├── docker-compose.dev.yml # Dev (Nginx)
|
||||||
├── docker-compose.yml # Prod (Traefik + SSL)
|
├── docker-compose.yml # Prod (Traefik + SSL)
|
||||||
├── nginx/
|
├── nginx/
|
||||||
├── uploads/ # Volume montato in /app/uploads
|
├── uploads/ # Volume montato in /app/uploads
|
||||||
|
├── vendor/
|
||||||
|
│ └── visionsuite/ # Submodule: libreria di visione (vs-core, vs-task, vs-measure, vs-pm2d)
|
||||||
├── docs/
|
├── docs/
|
||||||
└── src/
|
└── src/
|
||||||
├── backend/
|
├── backend/
|
||||||
@@ -38,16 +41,18 @@ TieMeasureFlow/
|
|||||||
│ ├── migrations/ # Alembic
|
│ ├── migrations/ # Alembic
|
||||||
│ ├── templates/ # Setup page
|
│ ├── templates/ # Setup page
|
||||||
│ └── tests/ # pytest
|
│ └── tests/ # pytest
|
||||||
└── frontend/
|
├── frontend/
|
||||||
└── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
│ └── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
||||||
├── app.py # giustificata: tablet UX server-side, USB calipers,
|
│ ├── app.py # giustificata: tablet UX server-side, USB calipers,
|
||||||
├── config.py # workflow operatore con Fabric.js)
|
│ ├── config.py # workflow operatore con Fabric.js)
|
||||||
├── blueprints/
|
│ ├── blueprints/
|
||||||
├── services/
|
│ ├── services/
|
||||||
├── templates/
|
│ ├── templates/
|
||||||
├── static/
|
│ ├── static/
|
||||||
├── translations/
|
│ ├── translations/
|
||||||
└── tests/
|
│ └── 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`):
|
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.
|
Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120s`, cache statica 7d, security headers duplicati a livello proxy.
|
||||||
|
|
||||||
|
## Visione (V3.0.0)
|
||||||
|
|
||||||
|
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
||||||
|
|
||||||
|
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 2e73691.
|
||||||
|
|
||||||
|
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
docker compose -f docker-compose.dev.yml build vision
|
||||||
|
```
|
||||||
|
|
||||||
|
Test del runner e del worker, in un ambiente Python 3.13 dedicato (con `uv run pytest` di default, su Python 3.11, questi test risultano *skipped*, non falliti):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --python 3.13 --extra vision --extra vision-worker --extra dev pytest src/vision/tests src/vision_worker/tests
|
||||||
|
```
|
||||||
|
|
||||||
## Convenzioni Codice
|
## Convenzioni Codice
|
||||||
- Python 3.11+, type hints ovunque
|
- Python 3.11+, type hints ovunque
|
||||||
- async/await per tutte le operazioni DB (server)
|
- async/await per tutte le operazioni DB (server)
|
||||||
@@ -311,3 +335,24 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
|||||||
- Font UI: Inter
|
- Font UI: Inter
|
||||||
- Font numeri: JetBrains Mono
|
- Font numeri: JetBrains Mono
|
||||||
- Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme`
|
- 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`.
|
||||||
|
- ⚠️ `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.
|
# 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.
|
# System libs required by WeasyPrint at runtime.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
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 \
|
shared-mime-info \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy lockfile + project metadata first to maximize Docker layer cache.
|
# 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/
|
COPY src/ ./src/
|
||||||
|
|
||||||
# Uploads directory (mounted as a volume in production).
|
# 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
|
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).
|
# 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.
|
# Non-root user the app runs as at runtime. -m creates a home dir: `uv run`
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
# in the CMD below writes its cache under $HOME, and without one it has
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
# nowhere writable to put it.
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
RUN useradd -m -u 10001 app
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -16,22 +37,24 @@ COPY pyproject.toml uv.lock ./
|
|||||||
COPY .python-version ./
|
COPY .python-version ./
|
||||||
RUN uv sync --frozen --no-dev --extra client
|
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/
|
COPY src/frontend/flask_app/ ./flask_app/
|
||||||
|
COPY --from=tailwind-build /app/flask_app/static/css/tailwind.css ./flask_app/static/css/tailwind.css
|
||||||
# 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
|
|
||||||
|
|
||||||
# Compile Flask-Babel translation catalogs.
|
# 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
|
EXPOSE 5000
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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.
|
||||||
|
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: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 ./
|
||||||
|
COPY vendor/ ./vendor/
|
||||||
|
RUN uv sync --extra vision-worker --frozen --no-dev
|
||||||
|
|
||||||
|
COPY src/vision/ ./src/vision/
|
||||||
|
COPY src/vision_worker/ ./src/vision_worker/
|
||||||
|
|
||||||
|
# Ruling R8: the version is stamped at build time, not discovered at runtime.
|
||||||
|
# COPY vendor/ above does not bring vendor/visionsuite/.git along - it is a
|
||||||
|
# gitlink file pointing at ../../.git/modules/vendor/visionsuite, which lives
|
||||||
|
# outside the build context - so `git rev-parse HEAD` cannot work in here.
|
||||||
|
# Pass the submodule's commit in from the host, which does have git:
|
||||||
|
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
# docker compose build vision
|
||||||
|
ARG VISION_ENGINE_VERSION
|
||||||
|
# I4: a build without the commit must fail now, at build time - not later, at
|
||||||
|
# every request including /health, with a container Docker still reports as
|
||||||
|
# healthy because no healthcheck existed to say otherwise.
|
||||||
|
RUN test -n "$VISION_ENGINE_VERSION" || { \
|
||||||
|
echo "VISION_ENGINE_VERSION is required. Build with:"; \
|
||||||
|
echo ' VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) docker compose build vision'; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
ENV VISION_ENGINE_VERSION=${VISION_ENGINE_VERSION}
|
||||||
|
|
||||||
|
RUN chown -R app:app /app /tmp/uv-cache
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 8100
|
||||||
|
|
||||||
|
# Two workers, not four: each holds the vision stack in memory.
|
||||||
|
CMD ["uv", "run", "uvicorn", "src.vision_worker.main:app", \
|
||||||
|
"--host", "0.0.0.0", "--port", "8100", "--workers", "2"]
|
||||||
@@ -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.
|
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni, build delle immagini con `uv` e configurazione Nginx in un solo comando.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clona il repository
|
# 1. Clona il repository CON il sottomodulo di VisionSuite (vendor/visionsuite):
|
||||||
git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
# 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
|
cd TieMeasureFlow
|
||||||
|
# Chi ha già clonato senza il sottomodulo:
|
||||||
|
# git submodule update --init
|
||||||
|
|
||||||
# 2. Configura le variabili d'ambiente
|
# 2. Configura le variabili d'ambiente
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
|
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
|
||||||
|
|
||||||
# 3. Avvia i servizi (ambiente di sviluppo)
|
# 3. Valorizza VISION_ENGINE_VERSION PRIMA della build: il worker di visione
|
||||||
docker compose -f docker-compose.dev.yml up -d --build
|
# (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
|
# 4. Verifica lo stato dei container
|
||||||
docker compose -f docker-compose.dev.yml ps
|
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 up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
||||||
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
| `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 server` | Segui log server in tempo reale |
|
||||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||||
| `docker compose ps` | Stato di tutti i servizi |
|
| `docker compose ps` | Stato di tutti i servizi |
|
||||||
@@ -386,7 +396,89 @@ uv run pytest --cov src/backend
|
|||||||
uv run pytest src/frontend/flask_app/tests/
|
uv run pytest src/frontend/flask_app/tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
Stato corrente su `V3.0.0`: **360 pass, 0 fail** (212 backend + 148 frontend).
|
Il modulo di visione (`src/vision/`) è un albero a parte, con un vincolo di
|
||||||
|
versione diverso dal resto del monorepo: le librerie di VisionSuite da cui
|
||||||
|
dipende richiedono Python 3.13 o superiore, mentre backend e frontend restano
|
||||||
|
su Python 3.11. Per questo motivo l'esecuzione di `uv run pytest` con
|
||||||
|
l'interprete di default mostra i test di `src/vision/tests/` come *skipped*,
|
||||||
|
non come falliti: il pacchetto viene rilevato ma la sua esecuzione reale
|
||||||
|
richiede un ambiente Python 3.13 dedicato, creato con il proprio extra `uv`.
|
||||||
|
Per eseguirli davvero:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prepara l'ambiente Python 3.13 con le dipendenze di visione
|
||||||
|
uv sync --extra vision --extra dev --python 3.13
|
||||||
|
|
||||||
|
# Esegue i test del runner di visione in quell'ambiente
|
||||||
|
uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/vision_worker/tests/` eredita lo stesso vincolo su Python 3.13, perché
|
||||||
|
il worker dipende da `src.vision.runner`, ma aggiunge dipendenze proprie -
|
||||||
|
FastAPI, Uvicorn, Pillow, python-multipart - dichiarate in un extra
|
||||||
|
separato, `vision-worker`, tenuto distinto da `vision` apposta: un
|
||||||
|
consumatore che incorpora solo il runner (come un futuro agente di stazione,
|
||||||
|
che non è un servizio web) non deve trascinarsi dietro un server che non gli
|
||||||
|
serve. `vision-worker` include comunque `vision`, quindi un solo extra basta
|
||||||
|
per avere un worker funzionante:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --python 3.13 --extra vision-worker --extra dev pytest src/vision_worker/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Per eseguire entrambe le suite insieme, nominando esplicitamente entrambi
|
||||||
|
gli extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --python 3.13 --extra vision --extra vision-worker --extra dev pytest src/vision/tests src/vision_worker/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Il worker di visione (`Dockerfile.vision`)
|
||||||
|
|
||||||
|
Il worker gira in un container separato dal server FastAPI principale, così
|
||||||
|
che l'immagine dell'API resti leggera e un aggiornamento di VisionSuite non
|
||||||
|
richieda di riavviare il traffico di produzione. Il container non pubblica
|
||||||
|
porte verso l'esterno: lo raggiunge solo il server, all'indirizzo interno
|
||||||
|
`http://vision:8100` sulla rete `tmflow-net`.
|
||||||
|
|
||||||
|
Ogni misura riporta il commit di VisionSuite che l'ha prodotta
|
||||||
|
(`engine_version`), perché una stazione che misura con un motore diverso da
|
||||||
|
quello atteso deve poter essere identificata. Il container, però, non ha
|
||||||
|
accesso al repository Git del progetto principale — `vendor/visionsuite` è un
|
||||||
|
submodule, e la copia `.git` che lo collega al repository ospitante non
|
||||||
|
viene inclusa nel contesto di build — quindi la versione non può essere
|
||||||
|
scoperta al volo dentro l'immagine. Va invece **stampata al momento del
|
||||||
|
build**, leggendo il commit dalla macchina che esegue `docker compose build`,
|
||||||
|
dove il repository Git è presente per intero:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
docker compose -f docker-compose.dev.yml build vision
|
||||||
|
```
|
||||||
|
|
||||||
|
La variabile viene passata come build argument (`ARG VISION_ENGINE_VERSION`
|
||||||
|
in `Dockerfile.vision`) e fissata nell'immagine come variabile d'ambiente, in
|
||||||
|
modo che il worker la trovi già pronta a ogni avvio senza doverla ricalcolare.
|
||||||
|
Fuori da un container, in un checkout di sviluppo locale, la stessa funzione
|
||||||
|
ricade su `git rev-parse` se la variabile non è impostata — comodo per
|
||||||
|
lavorare sul runner senza Docker. Ma se un'immagine viene costruita senza
|
||||||
|
passare `VISION_ENGINE_VERSION`, quel ripiego non ha nulla su cui appoggiarsi:
|
||||||
|
il `.git` del submodule non arriva nel contesto di build, e il worker
|
||||||
|
risponde con un errore esplicito su `/health` invece di indovinare una
|
||||||
|
versione o restituire `"unknown"`. La build va quindi sempre lanciata con la
|
||||||
|
variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando
|
||||||
|
mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml`
|
||||||
|
in produzione).
|
||||||
|
|
||||||
|
Stato corrente su `V3.0.0`, con `uv run --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. 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
|
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
|
||||||
proprietà che sopravvivono solo se qualcuno le controlla:
|
proprietà che sopravvivono solo se qualcuno le controlla:
|
||||||
@@ -413,6 +505,7 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||||
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||||
|
| `VISION_WORKER_URL` | Indirizzo interno del worker di visione (default: `http://vision:8100`, mai esposto fuori da `tmflow-net`) |
|
||||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||||
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||||
@@ -423,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
|
## Documentazione
|
||||||
|
|
||||||
Indice completo: [`docs/README.md`](docs/README.md).
|
Indice completo: [`docs/README.md`](docs/README.md).
|
||||||
|
|||||||
+52
-3
@@ -14,6 +14,7 @@ services:
|
|||||||
--authentication-policy=mysql_native_password
|
--authentication-policy=mysql_native_password
|
||||||
--character-set-server=utf8mb4
|
--character-set-server=utf8mb4
|
||||||
--collation-server=utf8mb4_unicode_ci
|
--collation-server=utf8mb4_unicode_ci
|
||||||
|
--default-time-zone=+00:00
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -31,13 +32,42 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
APP_ENV: dev
|
||||||
DB_HOST: mysql
|
DB_HOST: mysql
|
||||||
UPLOAD_DIR: uploads
|
UPLOAD_DIR: uploads
|
||||||
|
VISION_WORKER_URL: http://vision:8100
|
||||||
|
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
|
||||||
|
TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1}
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/app/uploads
|
- upload_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
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
|
||||||
|
|
||||||
|
vision:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.vision
|
||||||
|
args:
|
||||||
|
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
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
networks:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
|
|
||||||
@@ -45,14 +75,31 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
|
args:
|
||||||
|
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||||
|
APP_COMMIT: ${APP_COMMIT:-}
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
API_SERVER_URL: http://server:8000
|
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:
|
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:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
|
|
||||||
@@ -69,8 +116,10 @@ services:
|
|||||||
# - ./certbot/conf:/etc/letsencrypt:ro
|
# - ./certbot/conf:/etc/letsencrypt:ro
|
||||||
# - ./certbot/www:/var/www/certbot:ro
|
# - ./certbot/www:/var/www/certbot:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
server:
|
||||||
- client
|
condition: service_healthy
|
||||||
|
client:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
|
|
||||||
|
|||||||
+55
-8
@@ -4,16 +4,17 @@ services:
|
|||||||
container_name: tmflow-mysql
|
container_name: tmflow-mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
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_DATABASE: ${DB_NAME:-tiemeasureflow}
|
||||||
MYSQL_USER: ${DB_USER:-tmflow}
|
MYSQL_USER: ${DB_USER:-tmflow}
|
||||||
MYSQL_PASSWORD: ${DB_PASSWORD:-change_me_in_production}
|
MYSQL_PASSWORD: ${DB_PASSWORD:?manca DB_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- mysql_data:/var/lib/mysql
|
||||||
command: >
|
command: >
|
||||||
--authentication-policy=mysql_native_password
|
--authentication-policy=mysql_native_password
|
||||||
--character-set-server=utf8mb4
|
--character-set-server=utf8mb4
|
||||||
--collation-server=utf8mb4_unicode_ci
|
--collation-server=utf8mb4_unicode_ci
|
||||||
|
--default-time-zone=+00:00
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -31,52 +32,98 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
APP_ENV: production
|
||||||
DB_HOST: mysql
|
DB_HOST: mysql
|
||||||
UPLOAD_DIR: uploads
|
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:
|
volumes:
|
||||||
- upload_data:/app/uploads
|
- upload_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
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:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.entrypoints=web,websecure"
|
||||||
- "traefik.http.routers.tmflow-api.tls=true"
|
- "traefik.http.routers.tmflow-api.tls=true"
|
||||||
- "traefik.http.routers.tmflow-api.tls.certresolver=mytlschallenge"
|
- "traefik.http.routers.tmflow-api.tls.certresolver=mytlschallenge"
|
||||||
- "traefik.http.routers.tmflow-api.priority=100"
|
- "traefik.http.routers.tmflow-api.priority=100"
|
||||||
- "traefik.http.services.tmflow-api.loadbalancer.server.port=8000"
|
- "traefik.http.services.tmflow-api.loadbalancer.server.port=8000"
|
||||||
|
- "traefik.http.routers.tmflow-api.middlewares=tmflow-headers"
|
||||||
networks:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
- traefik-net
|
- traefik-net
|
||||||
|
|
||||||
|
vision:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.vision
|
||||||
|
args:
|
||||||
|
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
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- tmflow-net
|
||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
|
args:
|
||||||
|
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||||
|
APP_COMMIT: ${APP_COMMIT:-}
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
API_SERVER_URL: http://server:8000
|
API_SERVER_URL: http://server:8000
|
||||||
|
CLIENT_SECRET_KEY: ${CLIENT_SECRET_KEY:?manca CLIENT_SECRET_KEY}
|
||||||
depends_on:
|
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:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.entrypoints=web,websecure"
|
||||||
- "traefik.http.routers.tmflow-web.tls=true"
|
- "traefik.http.routers.tmflow-web.tls=true"
|
||||||
- "traefik.http.routers.tmflow-web.tls.certresolver=mytlschallenge"
|
- "traefik.http.routers.tmflow-web.tls.certresolver=mytlschallenge"
|
||||||
- "traefik.http.routers.tmflow-web.priority=10"
|
- "traefik.http.routers.tmflow-web.priority=10"
|
||||||
- "traefik.http.services.tmflow-web.loadbalancer.server.port=5000"
|
- "traefik.http.services.tmflow-web.loadbalancer.server.port=5000"
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.SSLRedirect=true"
|
# Traefik v3: SSLRedirect e browserXSSFilter non ci sono più (SSL redirect
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.STSSeconds=315360000"
|
# si fa a livello di entrypoint, l'XSS Auditor del browser non esiste più).
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.browserXSSFilter=true"
|
- "traefik.http.middlewares.tmflow-headers.headers.stsSeconds=315360000"
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.contentTypeNosniff=true"
|
- "traefik.http.middlewares.tmflow-headers.headers.contentTypeNosniff=true"
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.forceSTSHeader=true"
|
- "traefik.http.middlewares.tmflow-headers.headers.forceSTSHeader=true"
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.STSIncludeSubdomains=true"
|
- "traefik.http.middlewares.tmflow-headers.headers.STSIncludeSubdomains=true"
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.STSPreload=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"
|
- "traefik.http.routers.tmflow-web.middlewares=tmflow-headers"
|
||||||
networks:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
|
|||||||
+180
-281
@@ -142,6 +142,19 @@ SSL_KEYFILE=
|
|||||||
| `SSL_CERTFILE` | string | (empty) | Path to SSL certificate (production) |
|
| `SSL_CERTFILE` | string | (empty) | Path to SSL certificate (production) |
|
||||||
| `SSL_KEYFILE` | string | (empty) | Path to SSL private key (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
|
## Database Setup
|
||||||
@@ -169,63 +182,33 @@ EXIT;
|
|||||||
|
|
||||||
### 2. Run Database Migrations
|
### 2. Run Database Migrations
|
||||||
|
|
||||||
```bash
|
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/`.
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||||
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())
|
|
||||||
"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## 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
|
### Development Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
uv sync --extra server --extra dev
|
||||||
pip install -r requirements.txt
|
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs at `http://0.0.0.0:8000`
|
Runs at `http://0.0.0.0:8000`
|
||||||
@@ -234,40 +217,15 @@ API Documentation:
|
|||||||
- Swagger UI: `http://localhost:8000/docs`
|
- Swagger UI: `http://localhost:8000/docs`
|
||||||
- ReDoc: `http://localhost:8000/redoc`
|
- 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
|
```bash
|
||||||
pip install gunicorn
|
uv run uvicorn src.backend.main:app \
|
||||||
```
|
--host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY:-4} \
|
||||||
|
--proxy-headers --forwarded-allow-ips='*'
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### systemd Service (Linux)
|
### systemd Service (Linux)
|
||||||
@@ -280,14 +238,14 @@ Description=TieMeasureFlow API Server
|
|||||||
After=network.target mysql.service
|
After=network.target mysql.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=notify
|
Type=simple
|
||||||
User=tiemeasureflow
|
User=tiemeasureflow
|
||||||
WorkingDirectory=/opt/tiemeasureflow/server
|
WorkingDirectory=/opt/tiemeasureflow
|
||||||
Environment="PATH=/opt/tiemeasureflow/venv/bin"
|
EnvironmentFile=/opt/tiemeasureflow/.env
|
||||||
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn main:app \
|
Environment="WEB_CONCURRENCY=4"
|
||||||
--workers 4 \
|
ExecStart=/opt/tiemeasureflow/.venv/bin/uv run uvicorn src.backend.main:app \
|
||||||
--worker-class uvicorn.workers.UvicornWorker \
|
--host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY} \
|
||||||
--bind 0.0.0.0:8000
|
--proxy-headers --forwarded-allow-ips='*'
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
@@ -315,12 +273,14 @@ sudo journalctl -u tiemeasureflow-api -f
|
|||||||
|
|
||||||
## Client Deployment
|
## Client Deployment
|
||||||
|
|
||||||
|
Anche qui, Docker Compose è il metodo raccomandato; questa sezione è per lo
|
||||||
|
sviluppo o un'installazione manuale senza container.
|
||||||
|
|
||||||
### Development Server
|
### Development Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
uv sync --extra client --extra dev
|
||||||
pip install -r requirements.txt
|
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
|
||||||
flask run --host 0.0.0.0 --port 5000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs at `http://0.0.0.0: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)
|
### Compile Tailwind CSS (Optional)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
cd src/frontend/flask_app
|
||||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
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
|
```bash
|
||||||
pip install gunicorn
|
cd src/frontend/flask_app && uv run --project ../../.. gunicorn \
|
||||||
cd client
|
--workers 5 --threads 4 --worker-class gthread --timeout 60 \
|
||||||
gunicorn --workers 4 --bind 0.0.0.0:5000 app:app
|
--bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
|
||||||
|
"app:create_app()"
|
||||||
```
|
```
|
||||||
|
|
||||||
### systemd Service (Linux)
|
### systemd Service (Linux)
|
||||||
@@ -350,15 +314,14 @@ Description=TieMeasureFlow Web Client
|
|||||||
After=network.target tiemeasureflow-api.service
|
After=network.target tiemeasureflow-api.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=notify
|
Type=simple
|
||||||
User=tiemeasureflow
|
User=tiemeasureflow
|
||||||
WorkingDirectory=/opt/tiemeasureflow/client
|
WorkingDirectory=/opt/tiemeasureflow/src/frontend/flask_app
|
||||||
Environment="PATH=/opt/tiemeasureflow/venv/bin"
|
EnvironmentFile=/opt/tiemeasureflow/.env
|
||||||
Environment="FLASK_ENV=production"
|
ExecStart=/opt/tiemeasureflow/.venv/bin/uv run --project /opt/tiemeasureflow gunicorn \
|
||||||
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn \
|
--workers 5 --threads 4 --worker-class gthread --timeout 60 \
|
||||||
--workers 4 \
|
--bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
|
||||||
--bind 0.0.0.0:5000 \
|
"app:create_app()"
|
||||||
app:app
|
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
@@ -373,8 +336,9 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
### Security Checklist
|
### 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)
|
- [ ] 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
|
- [ ] Set CORS origins to actual client domains
|
||||||
- [ ] Enable SSL/HTTPS (see SSL/TLS section)
|
- [ ] Enable SSL/HTTPS (see SSL/TLS section)
|
||||||
- [ ] Configure firewall rules
|
- [ ] Configure firewall rules
|
||||||
@@ -386,200 +350,140 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
### Recommended Architecture
|
### 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)
|
On-premise (LAN) │ Nginx (:80) │
|
||||||
└────────┬────────┘
|
└─────────────┬────────────┘
|
||||||
│
|
│
|
||||||
┌────┴────┐
|
Produzione (dominio pubblico) → Traefik (:80/:443, TLS Let's Encrypt)
|
||||||
│ │
|
│
|
||||||
┌───▼──┐ ┌───▼──┐
|
┌───────────┴───────────┐
|
||||||
│API │ │Web │
|
│ │
|
||||||
│:8000 │ │:5000 │
|
┌─────▼─────┐ ┌──────▼─────┐
|
||||||
└──────┘ └──────┘
|
│ server │ │ client │
|
||||||
│ │
|
│ :8000 │ │ :5000 │
|
||||||
└────┬─────┘
|
└─────┬──────┘ └────────────┘
|
||||||
│
|
│
|
||||||
┌────▼────┐
|
┌─────▼──────┐ ┌─────────────┐
|
||||||
│ MySQL │
|
│ vision │ │ MySQL 8.0 │
|
||||||
│ :3306 │
|
│ :8100 │ │ :3306 │
|
||||||
└─────────┘
|
│ (interno) │ └─────────────┘
|
||||||
|
└─────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Nginx Configuration
|
Il worker di visione (`vision`) non pubblica porte verso l'esterno: lo
|
||||||
|
raggiunge solo il `server`, sulla rete Docker interna `tmflow-net`.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker Deployment
|
## 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
|
```bash
|
||||||
# Build images
|
# Sviluppo / on-premise
|
||||||
docker-compose build
|
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
docker compose -f docker-compose.dev.yml up -d --build
|
||||||
|
|
||||||
# Start services
|
# Produzione
|
||||||
docker-compose up -d
|
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
docker compose -f docker-compose.yml up -d --build
|
||||||
# View logs
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Stop services
|
|
||||||
docker-compose down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Docker Build
|
Comandi comuni (sintassi `docker compose` v2, senza trattino — il plugin, non
|
||||||
|
il vecchio `docker-compose` standalone):
|
||||||
#### 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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f server/Dockerfile -t tiemeasureflow-api .
|
docker compose -f docker-compose.yml ps
|
||||||
docker run -p 8000:8000 --env-file .env tiemeasureflow-api
|
docker compose -f docker-compose.yml logs -f server
|
||||||
|
docker compose -f docker-compose.yml down
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Web Client
|
### Aggiornare un'installazione esistente
|
||||||
|
|
||||||
```dockerfile
|
Le immagini precedenti creavano l'utente non privilegiato senza una home
|
||||||
FROM python:3.11-slim
|
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
|
Prima del primo avvio con le nuove immagini, va quindi corretta la proprietà
|
||||||
|
del volume:
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f client/Dockerfile -t tiemeasureflow-web .
|
docker compose -f docker-compose.yml run --rm --user root --entrypoint chown server -R 10001:10001 /app/uploads
|
||||||
docker run -p 5000:5000 --env-file .env tiemeasureflow-web
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(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
|
## 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
|
- **Produzione** (`docker-compose.yml`): Traefik ottiene e rinnova da solo un
|
||||||
openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes
|
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)
|
Un'installazione manuale (senza Docker, sezioni [Server](#server-deployment) e
|
||||||
|
[Client Deployment](#client-deployment) sopra) che debba terminare il TLS da
|
||||||
```bash
|
sola, dietro il proprio reverse proxy, può ancora valorizzare
|
||||||
sudo apt install certbot python3-certbot-nginx
|
`SSL_CERTFILE`/`SSL_KEYFILE` in `.env` — variabili lasciate per questo caso,
|
||||||
sudo certbot certonly --nginx -d yourdomain.com
|
non lette da uvicorn/gunicorn nei comandi mostrati sopra: vanno passate al
|
||||||
```
|
proprio reverse proxy o gestite fuori da questo progetto.
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -644,14 +548,11 @@ sqlalchemy.exc.OperationalError: (asyncmy.errors.ProgrammingError) (2003, "Can't
|
|||||||
|
|
||||||
**Check:**
|
**Check:**
|
||||||
```bash
|
```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"
|
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
|
grep DB_ .env
|
||||||
|
|
||||||
# Test connection string
|
|
||||||
cd server && python -c "from config import settings; print(settings.database_url)"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Port Already in Use
|
### Port Already in Use
|
||||||
@@ -667,7 +568,7 @@ lsof -i :8000
|
|||||||
kill -9 <PID>
|
kill -9 <PID>
|
||||||
|
|
||||||
# Or use different port
|
# Or use different port
|
||||||
uvicorn main:app --port 8001
|
uv run uvicorn src.backend.main:app --port 8001
|
||||||
```
|
```
|
||||||
|
|
||||||
### Import Errors
|
### Import Errors
|
||||||
@@ -678,21 +579,20 @@ ModuleNotFoundError: No module named 'fastapi'
|
|||||||
|
|
||||||
**Solution:**
|
**Solution:**
|
||||||
```bash
|
```bash
|
||||||
cd server
|
uv sync --extra server --extra client --extra dev
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### File Upload Not Working
|
### File Upload Not Working
|
||||||
|
|
||||||
```
|
```
|
||||||
/app/server/uploads: Permission denied
|
uploads: Permission denied
|
||||||
```
|
```
|
||||||
|
|
||||||
**Solution:**
|
**Solution:**
|
||||||
```bash
|
```bash
|
||||||
# Create uploads directory with correct permissions
|
# Create uploads directory with correct permissions (radice del repo, non server/uploads)
|
||||||
mkdir -p server/uploads
|
mkdir -p uploads/images uploads/pdfs uploads/logos uploads/reports
|
||||||
chmod 755 server/uploads
|
chmod 755 uploads
|
||||||
```
|
```
|
||||||
|
|
||||||
### Migrations Failed
|
### Migrations Failed
|
||||||
@@ -703,13 +603,12 @@ alembic.util.exc.CommandError: Can't locate revision identified by...
|
|||||||
|
|
||||||
**Solution:**
|
**Solution:**
|
||||||
```bash
|
```bash
|
||||||
cd server
|
|
||||||
# Check migration status
|
# Check migration status
|
||||||
alembic current
|
uv run alembic -c src/backend/migrations/alembic.ini current
|
||||||
|
|
||||||
# Reset migrations (CAUTION - deletes data)
|
# Reset migrations (CAUTION - deletes data)
|
||||||
alembic downgrade base
|
uv run alembic -c src/backend/migrations/alembic.ini downgrade base
|
||||||
alembic upgrade head
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
### API Key Not Working
|
### 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.
|
||||||
@@ -159,6 +159,14 @@ Sblocca tre cose che altrimenti non ci sarebbero:
|
|||||||
Quest'ultima è la rete di sicurezza che rende sostenibile aggiornare il
|
Quest'ultima è la rete di sicurezza che rende sostenibile aggiornare il
|
||||||
sottomodulo, con un motore che sta su due host.
|
sottomodulo, con un motore che sta su due host.
|
||||||
|
|
||||||
|
> **Non ancora consegnata.** La colonna `expected_json` esiste, ma nessun
|
||||||
|
> percorso la scrive — nemmeno `preview`, che è l'unico posto dove un grafo gira
|
||||||
|
> davvero su un'immagine. La prova di non regressione è quindi **promessa da
|
||||||
|
> questo documento e non mantenuta da nessuna riga di codice**. Diventa una
|
||||||
|
> consegna nominata del piano 1b: «`preview` può promuovere le proprie uscite a
|
||||||
|
> `expected_json`». Finché non è fatta, questa decisione non va dichiarata
|
||||||
|
> completa.
|
||||||
|
|
||||||
## Architettura
|
## Architettura
|
||||||
|
|
||||||
### I componenti
|
### I componenti
|
||||||
@@ -379,6 +387,73 @@ richiede una camera collegata.
|
|||||||
e i driver possono stare sulla macchina o nel container. La decisione è
|
e i driver possono stare sulla macchina o nel container. La decisione è
|
||||||
volutamente tardiva; il design la rende tale tenendo l'agente sottile e neutro.
|
volutamente tardiva; il design la rende tale tenendo l'agente sottile e neutro.
|
||||||
|
|
||||||
|
## Correzioni imposte dall'esecuzione
|
||||||
|
|
||||||
|
Il piano derivato da questo documento è stato eseguito il 16/08/2026. Sei cose
|
||||||
|
che qui erano scritte male o taciute, corrette dai fatti.
|
||||||
|
|
||||||
|
### Il gate del fuori tolleranza vale per l'acquisizione, non per la quota
|
||||||
|
|
||||||
|
La decisione 7 e il testo del punto 5 parlano di «la quota successiva». Quella
|
||||||
|
regola era stata pensata per la misura **manuale e sequenziale**, dove esiste
|
||||||
|
davvero un momento successivo in cui l'operatore potrebbe tirare avanti con una
|
||||||
|
lettura cattiva irrisolta.
|
||||||
|
|
||||||
|
Una camera produce tutte le N quote **nello stesso istante**. Dentro
|
||||||
|
un'acquisizione non esiste un «tirare avanti», quindi lì non c'è nulla da
|
||||||
|
presidiare — e applicare la regola sequenziale a un evento simultaneo produceva
|
||||||
|
un blocco permanente: la lettura fuori tolleranza non veniva mai registrata,
|
||||||
|
quindi nessuno poteva autorizzarla, e rifare la stessa foto ripeteva l'errore.
|
||||||
|
|
||||||
|
Il gate si valuta **una volta per acquisizione**: se la misura pendente è fra le
|
||||||
|
quote che questa acquisizione riprodurrà non blocca — riacquisire *è* rimisurare
|
||||||
|
quelle quote, che questo documento già dichiara la via d'uscita che non richiede
|
||||||
|
il capoturno. Fra due acquisizioni, e alla chiusura del ciclo, blocca come prima.
|
||||||
|
|
||||||
|
### Il repository è deliberatamente bi-ambiente
|
||||||
|
|
||||||
|
`vs-task`, `vs-measure` e `vs-pm2d` dichiarano `requires-python >= 3.13`;
|
||||||
|
`vs-core` si ferma a 3.10. Backend e frontend restano su **3.11**, l'albero della
|
||||||
|
visione gira su **3.13**, e l'extra `vision` porta i marker d'ambiente che
|
||||||
|
tengono separate le due risoluzioni.
|
||||||
|
|
||||||
|
Questo rafforza la decisione 4 più di come era argomentata: con VisionSuite dentro
|
||||||
|
il server FastAPI, questa scoperta avrebbe costretto l'intero backend a 3.13.
|
||||||
|
|
||||||
|
### La versione del motore si timbra al build
|
||||||
|
|
||||||
|
`vendor/visionsuite/.git` è un file che rimanda a `../../.git/modules/...`, che
|
||||||
|
nel contesto di build non esiste: interrogare git dentro il container non
|
||||||
|
funziona. `engine_version()` legge `VISION_ENGINE_VERSION` dall'ambiente, ripiega
|
||||||
|
su git nei checkout di sviluppo, e **alza un errore** se non risolve né l'uno né
|
||||||
|
l'altro. Il build fallisce se la variabile manca.
|
||||||
|
|
||||||
|
### La divisione degli extra compra esplicitezza, non peso
|
||||||
|
|
||||||
|
Gli extra sono due — `vision` per il runner, `vision-worker` per il servizio web
|
||||||
|
— ed è la forma giusta. Ma la motivazione che sembrava ovvia è falsa: `vs-pm2d`
|
||||||
|
dichiara **di suo** `fastapi`, `uvicorn`, `python-multipart` e `pillow`, quindi
|
||||||
|
un agente di stazione che fa pattern matching se li porterà dietro comunque. La
|
||||||
|
correzione vera è a monte — una libreria non dovrebbe dipendere da un server web
|
||||||
|
— e va aggiunta alle verifiche su VisionSuite qui sotto.
|
||||||
|
|
||||||
|
### L'immagine di produzione non viene ancora conservata
|
||||||
|
|
||||||
|
`vision_results.image_path` e `overlay_path` migrano e restano `NULL`: nulla le
|
||||||
|
scrive. Il flusso descritto in questo documento si chiude con «il client mostra
|
||||||
|
esito e immagine», e finché quelle colonne sono vuote una misura contestata non
|
||||||
|
ha prova materiale. Consegna nominata del piano 1b, insieme all'overlay.
|
||||||
|
|
||||||
|
### Prima di applicare le migrazioni in produzione
|
||||||
|
|
||||||
|
La 011 rinomina un valore di enum su una tabella in esercizio. Va contato prima
|
||||||
|
che nessuna riga usi `xf_compare`: in strict mode una riga sopravvissuta fa
|
||||||
|
fallire l'`ALTER` a metà deploy, fuori strict mode diventa `''` in silenzio.
|
||||||
|
Attenzione anche alla finestra: il `MODIFY` su `recipe_tasks` è una rinomina di
|
||||||
|
valore e userà `ALGORITHM=COPY`, mentre quello su `measurements.input_method`
|
||||||
|
aggiunge in coda e dovrebbe restare `INPLACE` — da confermare sulle dimensioni
|
||||||
|
reali, perché `measurements` è la tabella grossa.
|
||||||
|
|
||||||
## Da verificare prima di cominciare
|
## Da verificare prima di cominciare
|
||||||
|
|
||||||
VisionSuite è stato lavorato fino al 15/08/2026 e il suo ultimo commit è
|
VisionSuite è stato lavorato fino al 15/08/2026 e il suo ultimo commit è
|
||||||
|
|||||||
+8
-9
@@ -34,13 +34,6 @@ server {
|
|||||||
proxy_read_timeout 120s;
|
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
|
# All other requests → Flask client
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://client_frontend;
|
proxy_pass http://client_frontend;
|
||||||
@@ -50,12 +43,18 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Static file caching
|
# Static file caching. Limitata a /static/: un add_header in questo
|
||||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
# 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_pass http://client_frontend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
expires 7d;
|
expires 7d;
|
||||||
add_header Cache-Control "public, immutable";
|
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)
|
# 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,477 @@
|
|||||||
|
"""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`.
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ctypes
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- rete
|
||||||
|
|
||||||
|
def _porta_aperta(porta: int) -> bool:
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", porta), timeout=0.5):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _e_tmf() -> bool:
|
||||||
|
# Una copia gia' in esecuzione: il backend risponde su /api/health con il nome del servizio
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"http://127.0.0.1:{PORTA_BACKEND}/api/health", timeout=2) as r:
|
||||||
|
return "TieMeasureFlow" in str(json.load(r).get("service", ""))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _nessun_utente(db: Path) -> bool:
|
||||||
|
"""True se il database esiste ed e' senza utenti (primo avvio: serve la pagina di setup)."""
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
|
||||||
|
try:
|
||||||
|
return con.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 0
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
|
||||||
|
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
|
||||||
|
for _ in range(300):
|
||||||
|
if all(_porta_aperta(p) for p in porte):
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- migrazioni
|
||||||
|
|
||||||
|
def _neutralizza_fileconfig() -> None:
|
||||||
|
# env.py di Alembic chiama logging.config.fileConfig (anche la pagina di setup, con command.stamp):
|
||||||
|
# azzererebbe i gestori del log su file. Nel pacchetto il log lo decide avvio.py.
|
||||||
|
import logging.config
|
||||||
|
logging.config.fileConfig = lambda *a, **k: None
|
||||||
|
|
||||||
|
|
||||||
|
def esegui_migrazioni() -> None:
|
||||||
|
"""alembic upgrade head. Compilato: env.py e versions\\ sono soli .pyc, quindi sourceless."""
|
||||||
|
_neutralizza_fileconfig()
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
import src.backend as pacchetto
|
||||||
|
migrazioni = Path(pacchetto.__file__).resolve().parent / "migrations"
|
||||||
|
cfg = Config(str(migrazioni / "alembic.ini"))
|
||||||
|
if COMPILATO:
|
||||||
|
cfg.set_main_option("sourceless", "true")
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- server
|
||||||
|
|
||||||
|
class ServerThread(threading.Thread):
|
||||||
|
"""Un server (uvicorn o waitress) in un thread; `errore` resta valorizzato se muore."""
|
||||||
|
|
||||||
|
def __init__(self, nome: str, costruisci):
|
||||||
|
super().__init__(name=nome, daemon=True)
|
||||||
|
self._costruisci = costruisci
|
||||||
|
self.server = None
|
||||||
|
self.errore: BaseException | None = None
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
self.server = self._costruisci()
|
||||||
|
self.server.run()
|
||||||
|
except BaseException as e: # un server morto deve comparire nel log e fermare l'insieme
|
||||||
|
self.errore = e
|
||||||
|
log.exception("server %s terminato con errore", self.name)
|
||||||
|
|
||||||
|
def ferma(self) -> None:
|
||||||
|
s = self.server
|
||||||
|
try:
|
||||||
|
if hasattr(s, "should_exit"):
|
||||||
|
s.should_exit = True # uvicorn
|
||||||
|
elif s is not None:
|
||||||
|
s.close() # waitress
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _uvicorn(app, porta: int):
|
||||||
|
import uvicorn
|
||||||
|
# log_config=None: niente console; i thread non toccano i signal handler (uvicorn lo gestisce)
|
||||||
|
return uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=porta, log_config=None, workers=1))
|
||||||
|
|
||||||
|
|
||||||
|
def costruisci_web(host: str):
|
||||||
|
import waitress
|
||||||
|
from app import create_app # src/frontend/flask_app/app.py
|
||||||
|
return waitress.create_server(create_app(), host=host, port=PORTA_WEB, threads=8)
|
||||||
|
|
||||||
|
|
||||||
|
def costruisci_backend():
|
||||||
|
from src.backend.main import app
|
||||||
|
return _uvicorn(app, PORTA_BACKEND)
|
||||||
|
|
||||||
|
|
||||||
|
def costruisci_vision():
|
||||||
|
from src.vision_worker.main import app
|
||||||
|
return _uvicorn(app, PORTA_VISION)
|
||||||
|
|
||||||
|
|
||||||
|
def costruisci_camera(cartella: Path):
|
||||||
|
from camera_service import create_app # scripts/camera_service.py (non e' un pacchetto)
|
||||||
|
return _uvicorn(create_app(cartella), PORTA_CAMERA)
|
||||||
|
|
||||||
|
|
||||||
|
def avvia_server(host: str, dati: Path) -> list[ServerThread]:
|
||||||
|
thread = [
|
||||||
|
ServerThread("backend", costruisci_backend),
|
||||||
|
ServerThread("vision", costruisci_vision),
|
||||||
|
ServerThread("camera", lambda: costruisci_camera(dati / "camera")),
|
||||||
|
ServerThread("web", lambda: costruisci_web(host)),
|
||||||
|
]
|
||||||
|
for t in thread:
|
||||||
|
t.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
_stream_sicuri()
|
||||||
|
p = argparse.ArgumentParser(prog="TieMeasureFlow", description=TITOLO)
|
||||||
|
p.add_argument("--impronta", action="store_true", help="mostra l'impronta di questo computer")
|
||||||
|
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
|
||||||
|
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
|
||||||
|
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
thread = avvia_server(args.host, dati)
|
||||||
|
if not args.senza_browser:
|
||||||
|
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
||||||
|
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
|
||||||
|
else f"http://127.0.0.1:{PORTA_WEB}/")
|
||||||
|
threading.Thread(target=_apri_browser_quando_pronto,
|
||||||
|
args=(url, (PORTA_WEB, PORTA_BACKEND)), daemon=True).start()
|
||||||
|
|
||||||
|
# Si resta vivi finche' tutti i server girano; se uno muore si ferma tutto
|
||||||
|
try:
|
||||||
|
while thread and all(t.is_alive() for t in thread):
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
morti = [t for t in thread if not t.is_alive()]
|
||||||
|
for t in thread:
|
||||||
|
t.ferma()
|
||||||
|
if morti:
|
||||||
|
messaggio("Un componente si e' fermato: " + ", ".join(f"{t.name} ({t.errore})" for t in morti)
|
||||||
|
+ f"\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}", senza_finestre=args.senza_finestre)
|
||||||
|
return 4
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<#
|
||||||
|
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.
|
||||||
|
⚠️ 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-*: nulla li legge e Nuitka rifiuta i metadati di una
|
||||||
|
# distribuzione il cui pacchetto (visionsuite.camera, namespace) non e' di primo livello.
|
||||||
|
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 ';')
|
||||||
|
|
||||||
|
$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")" }
|
||||||
|
|
||||||
|
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,197 @@
|
|||||||
|
"""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
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
RADICE = Path(__file__).resolve().parents[3]
|
||||||
|
# vettore di prova del contratto licenza v1 (chiave finta, solo test)
|
||||||
|
SEED_TEST = base64.b64decode("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
|
||||||
|
PUBBLICA_TEST = base64.b64decode("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
|
||||||
|
IMPRONTA = "ab" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def _firma(dati: dict) -> str:
|
||||||
|
# stessa canonicalizzazione del contratto (byte firmati)
|
||||||
|
byte = json.dumps(dati, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
return base64.b64encode(Ed25519PrivateKey.from_private_bytes(SEED_TEST).sign(byte)).decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def avvio(monkeypatch, tmp_path):
|
||||||
|
# avvio.py non e' in un pacchetto: lo si carica dal percorso
|
||||||
|
spec = importlib.util.spec_from_file_location("avvio_tmf", RADICE / "packaging/windows/avvio.py")
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
monkeypatch.setenv("PROGRAMDATA", str(tmp_path)) # ProgramData finta
|
||||||
|
monkeypatch.delenv("TIELOGIC_LICENZA", raising=False)
|
||||||
|
monkeypatch.setattr(os, "environ", os.environ.copy()) # le variabili impostate da avvio non escono dal test
|
||||||
|
monkeypatch.setattr(m.lic, "impronta", lambda: IMPRONTA)
|
||||||
|
# verifica reale (formato, firma, prodotto, macchina, scadenza) ma con la chiave di prova
|
||||||
|
verifica_reale = m.lic.verifica
|
||||||
|
monkeypatch.setattr(m.lic, "verifica", functools.partial(
|
||||||
|
verifica_reale, chiave_pubblica=PUBBLICA_TEST, impronta_attesa=IMPRONTA))
|
||||||
|
m.chiamate = []
|
||||||
|
monkeypatch.setattr(m, "avvia_server", lambda host, dati: m.chiamate.append((host, dati)) or [])
|
||||||
|
monkeypatch.setattr(m, "esegui_migrazioni", lambda: m.chiamate.append("migrazioni"))
|
||||||
|
m.aperti = []
|
||||||
|
monkeypatch.setattr(m.webbrowser, "open", lambda url: m.aperti.append(url))
|
||||||
|
monkeypatch.setattr(m, "_prepara_log", lambda dati=None: None)
|
||||||
|
monkeypatch.setattr(m, "_porta_aperta", lambda porta: False)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _scrivi_licenza(tmp_path: Path, **modifiche) -> Path:
|
||||||
|
dati = {"versione": 1, "prodotto": "tiemeasureflow", "cliente": "Prova Srl", "impronta": IMPRONTA,
|
||||||
|
"emessa": date.today().isoformat(), "scadenza": (date.today() + timedelta(days=30)).isoformat()}
|
||||||
|
dati.update(modifiche)
|
||||||
|
p = tmp_path / "Tielogic" / "licenze" / "tiemeasureflow.json"
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(json.dumps({"dati": dati, "firma": _firma(dati)}), encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_licenza_mancante_non_avvia_e_scrive_impronta(avvio, capsys):
|
||||||
|
assert avvio.main(["--senza-finestre"]) == 1
|
||||||
|
assert avvio.chiamate == [] and avvio.aperti == []
|
||||||
|
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "Licenza non trovata" in err and IMPRONTA in err and "tiemeasureflow.json" in err
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("modifica,testo", [
|
||||||
|
({"scadenza": "2020-01-01"}, "scaduta"),
|
||||||
|
({"impronta": "cd" * 32}, "altra macchina"),
|
||||||
|
({"prodotto": "tievision-studio"}, "altro prodotto"),
|
||||||
|
])
|
||||||
|
def test_licenza_non_valida_non_avvia(avvio, tmp_path, capsys, modifica, testo):
|
||||||
|
_scrivi_licenza(tmp_path, **modifica)
|
||||||
|
assert avvio.main(["--senza-finestre"]) == 1
|
||||||
|
assert avvio.chiamate == []
|
||||||
|
assert testo in capsys.readouterr().err
|
||||||
|
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
|
||||||
|
|
||||||
|
|
||||||
|
def test_licenza_manomessa_non_avvia(avvio, tmp_path, capsys):
|
||||||
|
p = _scrivi_licenza(tmp_path)
|
||||||
|
doc = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
doc["dati"]["cliente"] = "Altro Cliente" # dati cambiati dopo la firma
|
||||||
|
p.write_text(json.dumps(doc), encoding="utf-8")
|
||||||
|
assert avvio.main(["--senza-finestre"]) == 1
|
||||||
|
assert "Firma" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_licenza_valida_prepara_dati_e_avvia(avvio, tmp_path):
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
assert avvio.main(["--senza-finestre", "--senza-browser", "--host", "0.0.0.0"]) == 0
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
# prima le migrazioni, poi i server
|
||||||
|
assert avvio.chiamate == ["migrazioni", ("0.0.0.0", dati)]
|
||||||
|
assert (dati / "config.env").is_file() and (dati / "uploads").is_dir() and (dati / "camera").is_dir()
|
||||||
|
assert not avvio.percorso_impronta().exists() # con licenza valida non si scrive nulla
|
||||||
|
assert avvio.aperti == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_variabili_del_contratto(avvio, tmp_path):
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
avvio.main(["--senza-finestre", "--senza-browser"])
|
||||||
|
e = os.environ
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
assert e["APP_ENV"] == "production"
|
||||||
|
assert e["DB_URL"] == f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}" and "\\" not in e["DB_URL"]
|
||||||
|
assert e["UPLOAD_DIR"] == str(dati / "uploads") == e["VISION_RECIPES_ROOT"]
|
||||||
|
assert e["VISION_WORKER_URL"] == "http://127.0.0.1:8100"
|
||||||
|
assert e["API_SERVER_URL"] == "http://127.0.0.1:8000"
|
||||||
|
assert e["SESSION_COOKIE_SECURE"] == "0"
|
||||||
|
for k in ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD"):
|
||||||
|
assert len(e[k]) >= 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path):
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
primo = avvio.carica_segreti(dati)
|
||||||
|
testo = (dati / "config.env").read_text(encoding="utf-8")
|
||||||
|
assert set(primo) == {"SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD", "STATION_CODE"}
|
||||||
|
assert len({primo[k] for k in avvio.SEGRETI}) == 4 # segreti distinti
|
||||||
|
assert primo["STATION_CODE"] == "ST-DEFAULT"
|
||||||
|
secondo = avvio.carica_segreti(dati)
|
||||||
|
assert secondo == primo and (dati / "config.env").read_text(encoding="utf-8") == testo
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_env_completa_i_segreti_mancanti(avvio, tmp_path):
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
dati.mkdir(parents=True)
|
||||||
|
(dati / "config.env").write_text("# nota\nSETUP_PASSWORD=scelta-da-me\n", encoding="utf-8")
|
||||||
|
v = avvio.carica_segreti(dati)
|
||||||
|
assert v["SETUP_PASSWORD"] == "scelta-da-me" and len(v["SERVER_SECRET_KEY"]) >= 32
|
||||||
|
assert avvio.carica_segreti(dati) == v
|
||||||
|
|
||||||
|
|
||||||
|
def test_seconda_istanza_apre_solo_il_browser(avvio, tmp_path, monkeypatch):
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||||
|
monkeypatch.setattr(avvio, "_e_tmf", lambda: True)
|
||||||
|
assert avvio.main(["--senza-finestre"]) == 0
|
||||||
|
assert avvio.aperti == ["http://127.0.0.1:8780/"] and avvio.chiamate == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_porta_occupata_da_altro_programma(avvio, tmp_path, monkeypatch, capsys):
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||||
|
monkeypatch.setattr(avvio, "_e_tmf", lambda: False)
|
||||||
|
assert avvio.main(["--senza-finestre"]) == 2
|
||||||
|
assert "8780" in capsys.readouterr().err and avvio.chiamate == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrazione_fallita_esce_con_3(avvio, tmp_path, monkeypatch, capsys):
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
|
||||||
|
def rotta():
|
||||||
|
raise RuntimeError("db bloccato")
|
||||||
|
|
||||||
|
monkeypatch.setattr(avvio, "esegui_migrazioni", rotta)
|
||||||
|
assert avvio.main(["--senza-finestre"]) == 3
|
||||||
|
assert "db bloccato" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_impronta_si_stampa_senza_licenza(avvio, capsys):
|
||||||
|
assert avvio.main(["--impronta", "--senza-finestre"]) == 0
|
||||||
|
assert IMPRONTA in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_nessun_utente_rileva_il_primo_avvio(avvio, tmp_path):
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
assert avvio._nessun_utente(db) is False # file assente: nessuna conclusione
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
con.execute("CREATE TABLE users (id INTEGER)")
|
||||||
|
con.commit()
|
||||||
|
assert avvio._nessun_utente(db) is True
|
||||||
|
con.execute("INSERT INTO users VALUES (1)")
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
assert avvio._nessun_utente(db) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(avvio, tmp_path):
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
dati.mkdir(parents=True)
|
||||||
|
(dati / "config.env").write_text("STATION_CODE=ST-FIERA\nSTATION_SWITCH_ENABLED=1\nAPP_ENV=dev\n", encoding="utf-8")
|
||||||
|
avvio.main(["--senza-finestre", "--senza-browser"])
|
||||||
|
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
|
||||||
|
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
|
||||||
+54
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "tiemeasureflow"
|
name = "tiemeasureflow"
|
||||||
version = "2.0.0"
|
version = "3.0.0"
|
||||||
description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations."
|
description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
authors = [
|
authors = [
|
||||||
@@ -53,6 +53,48 @@ dev = [
|
|||||||
"coverage>=7.0.0",
|
"coverage>=7.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Vision runner (VisionSuite submodule): what src/vision/runner.py needs to
|
||||||
|
# execute a graph. vs-task/vs-measure/vs-pm2d require Python >=3.13 upstream;
|
||||||
|
# marker keeps the base 3.11 resolution untouched. numpy is declared
|
||||||
|
# explicitly - runner.py imports it directly, and it must not depend on
|
||||||
|
# being pulled in as a side effect of vs-pm2d's own dependencies.
|
||||||
|
vision = [
|
||||||
|
"visionsuite; python_version>='3.13'",
|
||||||
|
"vs-task; python_version>='3.13'",
|
||||||
|
"vs-measure; python_version>='3.13'",
|
||||||
|
"vs-pm2d; python_version>='3.13'",
|
||||||
|
# #46: tutti gli strumenti della suite (locate/MatchPattern e dxf/CompareDxf)
|
||||||
|
"vs-tools[locate,dxf]; python_version>='3.13'",
|
||||||
|
"numpy; python_version>='3.13'",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Vision worker (src/vision_worker/): the FastAPI shell around the runner,
|
||||||
|
# on top of `vision`. Kept separate on purpose (Ruling R9) - VisionSuite's
|
||||||
|
# whole structure exists so a consumer installs only what it needs, and a
|
||||||
|
# later station agent embeds the same runner without being a web service.
|
||||||
|
# It must not drag fastapi/uvicorn onto shop-floor PCs.
|
||||||
|
vision-worker = [
|
||||||
|
"tiemeasureflow[vision]",
|
||||||
|
"fastapi>=0.110.0; python_version>='3.13'",
|
||||||
|
"uvicorn[standard]>=0.30.0; python_version>='3.13'",
|
||||||
|
"pillow>=10.0.0; python_version>='3.13'",
|
||||||
|
"python-multipart>=0.0.6; python_version>='3.13'",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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]
|
[project.scripts]
|
||||||
# Backend
|
# Backend
|
||||||
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
||||||
@@ -70,6 +112,16 @@ packages = ["src/backend", "src/frontend"]
|
|||||||
# Pin the resolver to the deps we declared; reproducible builds.
|
# Pin the resolver to the deps we declared; reproducible builds.
|
||||||
package = false
|
package = false
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
|
||||||
|
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
|
||||||
|
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
|
||||||
|
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
||||||
|
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]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests"]
|
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests", "src/vision/tests", "src/vision_worker/tests"]
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Servizio camera minimo di stazione (TieMeasureFlow, #72).
|
||||||
|
|
||||||
|
Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
|
||||||
|
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
||||||
|
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
||||||
|
GET /camera/sources -> {"sources": [{name, kind, label}]}
|
||||||
|
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG
|
||||||
|
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
||||||
|
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
|
||||||
|
raggiunge; questo servizio permette di scegliere l'host di ascolto.
|
||||||
|
|
||||||
|
Uso (dalla radice del repo):
|
||||||
|
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \
|
||||||
|
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
|
||||||
|
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
||||||
|
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
|
||||||
|
|
||||||
|
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
||||||
|
vs-camera, fastapi, uvicorn, opencv.
|
||||||
|
|
||||||
|
AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
|
||||||
|
camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import threading
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
from fastapi import Body, FastAPI, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from visionsuite.camera.camera_interface import CameraError, CameraGrabError
|
||||||
|
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
|
||||||
|
|
||||||
|
_TRIGGER = ("continuo",)
|
||||||
|
|
||||||
|
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
|
||||||
|
_MESSAGGI = {
|
||||||
|
"Camera is not open": "La camera non è aperta",
|
||||||
|
"Camera is not grabbing": "La camera non sta acquisendo",
|
||||||
|
"Grab cancelled (grabbing stopped)": "Acquisizione annullata (acquisizione fermata)",
|
||||||
|
"Failed to read frame from RTSP stream": "Lettura del fotogramma dallo stream RTSP non riuscita",
|
||||||
|
}
|
||||||
|
_PREFISSI = (
|
||||||
|
("Failed to open OpenCV device", "Apertura del dispositivo OpenCV non riuscita"),
|
||||||
|
("Failed to open RTSP stream", "Apertura dello stream RTSP non riuscita"),
|
||||||
|
("Failed to reconnect to RTSP stream", "Riconnessione allo stream RTSP non riuscita"),
|
||||||
|
("Failed to read frame after", "Lettura del fotogramma non riuscita dopo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _traduci(messaggio: str) -> str:
|
||||||
|
if messaggio in _MESSAGGI:
|
||||||
|
return _MESSAGGI[messaggio]
|
||||||
|
for inglese, italiano in _PREFISSI:
|
||||||
|
if messaggio.startswith(inglese):
|
||||||
|
return italiano + messaggio[len(inglese):]
|
||||||
|
return messaggio
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(folder: Path | str | None = None) -> FastAPI:
|
||||||
|
aperte: dict = {}
|
||||||
|
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||||
|
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||||
|
|
||||||
|
def _config() -> dict:
|
||||||
|
if folder is None:
|
||||||
|
return {}
|
||||||
|
return {"folder": {"folder": str(folder), "loop": True}}
|
||||||
|
|
||||||
|
def _lock_di(nome: str) -> threading.Lock:
|
||||||
|
with lock:
|
||||||
|
return lock_sorgenti.setdefault(nome, threading.Lock())
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _vita(_app):
|
||||||
|
yield
|
||||||
|
with lock: # alla chiusura rilascia le camere aperte
|
||||||
|
sorgenti = list(aperte.values())
|
||||||
|
aperte.clear()
|
||||||
|
for s in sorgenti:
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
app = FastAPI(title="TMF camera service", lifespan=_vita)
|
||||||
|
|
||||||
|
@app.get("/camera/sources")
|
||||||
|
def camera_sources():
|
||||||
|
voci = list_sources(None, cameras=True, config=_config())
|
||||||
|
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||||
|
for v in voci if v.kind in ("camera", "virtual")]}
|
||||||
|
|
||||||
|
@app.post("/camera/grab")
|
||||||
|
def camera_grab(corpo: dict = Body(default=None)):
|
||||||
|
nome = corpo.get("source") if isinstance(corpo, dict) else None
|
||||||
|
if not isinstance(nome, str) or not nome.strip():
|
||||||
|
raise HTTPException(400, "manca «source»")
|
||||||
|
nome = nome.strip()
|
||||||
|
trigger = corpo.get("trigger", "continuo")
|
||||||
|
if trigger not in _TRIGGER:
|
||||||
|
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
||||||
|
timeout = corpo.get("timeout_ms", 5000)
|
||||||
|
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||||
|
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||||
|
try:
|
||||||
|
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
|
||||||
|
with lock:
|
||||||
|
sorgente = aperte.get(nome)
|
||||||
|
if sorgente is None:
|
||||||
|
sorgente = open_source(nome, None, config=_config())
|
||||||
|
with lock:
|
||||||
|
aperte[nome] = sorgente
|
||||||
|
frame = sorgente.grab(timeout)
|
||||||
|
except SourceNotFound as e:
|
||||||
|
raise HTTPException(404, _traduci(str(e)))
|
||||||
|
except TimeoutError as e:
|
||||||
|
raise HTTPException(504, _traduci(str(e)) or "timeout")
|
||||||
|
except (CameraGrabError, CameraError) as e:
|
||||||
|
raise HTTPException(503, _traduci(str(e)))
|
||||||
|
ok, buf = cv2.imencode(".png", frame)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(500, "codifica PNG fallita")
|
||||||
|
return Response(buf.tobytes(), media_type="image/png")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import uvicorn
|
||||||
|
p = argparse.ArgumentParser(description="Servizio camera minimo di stazione (#72)")
|
||||||
|
p.add_argument("--host", default="127.0.0.1")
|
||||||
|
p.add_argument("--port", type=int, default=8765)
|
||||||
|
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder")
|
||||||
|
a = p.parse_args()
|
||||||
|
uvicorn.run(create_app(a.folder), host=a.host, port=a.port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+90
-11
@@ -21,16 +21,28 @@ are already in place:
|
|||||||
|
|
||||||
Re-running finds the recipes already there and stops. --replace deletes them
|
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
|
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 argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, func, select
|
||||||
|
|
||||||
sys.path.insert(0, "/app")
|
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.database import async_session_factory # noqa: E402
|
||||||
from src.backend.models.orm.measurement import Measurement # noqa: E402
|
from src.backend.models.orm.measurement import Measurement # noqa: E402
|
||||||
from src.backend.models.orm.production import ( # 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())
|
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):
|
async def _delete_recipes(session, recipes):
|
||||||
"""Remove the collaudo recipes and everything measured against them."""
|
"""Remove the collaudo recipes and everything measured against them."""
|
||||||
for recipe in recipes:
|
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
|
An admin is accepted by the authorisation check, but the acceptance session
|
||||||
should exercise the role the shop floor will actually use.
|
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(
|
existing = (await session.execute(
|
||||||
select(User).where(User.username == username)
|
select(User).where(User.username == username)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if existing is not 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(
|
user = await auth_service.create_user(
|
||||||
session,
|
session,
|
||||||
username=username,
|
username=username,
|
||||||
password=password,
|
password=password_used,
|
||||||
display_name="Capo Turno (collaudo)",
|
display_name="Capo Turno (collaudo)",
|
||||||
roles=["Supervisor", "MeasurementTec"],
|
roles=["Supervisor", "MeasurementTec"],
|
||||||
)
|
)
|
||||||
await session.flush()
|
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:
|
async with async_session_factory() as session:
|
||||||
owner = (await session.execute(
|
owner = (await session.execute(
|
||||||
select(User).where(User.is_admin == True) # noqa: E712
|
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).")
|
print("Usa --replace per rifarle da zero (cancella anche le misure).")
|
||||||
return 1
|
return 1
|
||||||
if existing:
|
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)
|
await _delete_recipes(session, existing)
|
||||||
print(f"Rimosse: {', '.join(r.code for r in 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)
|
await _assign(session, station, recipe_b, owner.id)
|
||||||
|
|
||||||
supervisor, created = await _ensure_supervisor(
|
supervisor, created, password_used = await _ensure_supervisor(
|
||||||
session, "capoturno", supervisor_password,
|
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-A ciclo di misura, lotto obbligatorio, intervallo 2 min")
|
||||||
print(" COLLAUDO-B solo calibro, lotto e seriale obbligatori")
|
print(" COLLAUDO-B solo calibro, lotto e seriale obbligatori")
|
||||||
if created:
|
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.")
|
print(" cambiare la password prima di qualunque uso reale.")
|
||||||
else:
|
else:
|
||||||
print(f"Capoturno gia' presente: {supervisor.username} (password invariata)")
|
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")
|
help="codice della stazione a cui assegnare le ricette")
|
||||||
parser.add_argument("--replace", action="store_true",
|
parser.add_argument("--replace", action="store_true",
|
||||||
help="cancella le ricette di collaudo esistenti e le misure fatte su di esse")
|
help="cancella le ricette di collaudo esistenti e le misure fatte su di esse")
|
||||||
parser.add_argument("--supervisor-password", default="Collaudo2026!",
|
parser.add_argument("--si", action="store_true",
|
||||||
help="password del capoturno, se va creato")
|
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()
|
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(
|
return asyncio.run(
|
||||||
seed(args.station, args.replace, args.supervisor_password)
|
seed(args.station, args.replace, args.supervisor_password, args.si)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
"""API Key authentication dependency for FastAPI."""
|
"""API Key authentication dependency for FastAPI."""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
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.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(
|
async def get_current_user(
|
||||||
@@ -24,15 +32,25 @@ async def get_current_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
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
|
# Store user_id in request state for access logging middleware
|
||||||
request.state.user_id = user.id
|
request.state.user_id = user.id
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ from fastapi import Request, Response
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from sqlalchemy import insert
|
from sqlalchemy import insert
|
||||||
|
|
||||||
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
from src.backend.database import async_session_factory
|
from src.backend.database import async_session_factory
|
||||||
from src.backend.models.orm.access_log import AccessLog
|
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):
|
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||||
"""Middleware that logs every API request to the access_logs table."""
|
"""Middleware that logs every API request to the access_logs table."""
|
||||||
@@ -30,11 +35,12 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
# Log asynchronously (don't block response)
|
# Log asynchronously (don't block response)
|
||||||
try:
|
try:
|
||||||
|
action = f"{request.method} {request.url.path}"[:_ACTION_MAX_LENGTH]
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
await session.execute(
|
await session.execute(
|
||||||
insert(AccessLog).values(
|
insert(AccessLog).values(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
action=f"{request.method} {request.url.path}",
|
action=action,
|
||||||
details={
|
details={
|
||||||
"method": request.method,
|
"method": request.method,
|
||||||
"path": request.url.path,
|
"path": request.url.path,
|
||||||
@@ -42,7 +48,7 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
|||||||
"status_code": response.status_code,
|
"status_code": response.status_code,
|
||||||
"duration_ms": round(duration_ms, 2),
|
"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],
|
user_agent=request.headers.get("user-agent", "")[:500],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Rate limiting middleware for FastAPI.
|
"""Rate limiting middleware for FastAPI.
|
||||||
|
|
||||||
Implements in-memory sliding window rate limiting per client IP.
|
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
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
@@ -17,14 +19,34 @@ from src.backend.config import settings
|
|||||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
"""Middleware that enforces per-IP rate limits using a sliding window.
|
"""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.
|
- All other endpoints: limited to `rate_limit_general` req/min.
|
||||||
Returns HTTP 429 with Retry-After header when limit exceeded.
|
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"
|
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
|
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:
|
def __init__(self, app) -> None:
|
||||||
super().__init__(app)
|
super().__init__(app)
|
||||||
# {ip: [timestamp, ...]} per bucket
|
# {ip: [timestamp, ...]} per bucket
|
||||||
@@ -34,23 +56,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _client_ip(request: Request) -> str:
|
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``,
|
Nginx (and, behind it, the Flask client) each add one hop to
|
||||||
``request.client.host``. Required because Nginx and the Flask client
|
X-Forwarded-For. The *leftmost* entries are whatever the original
|
||||||
sit between the tablet and the API; without parsing these headers
|
caller claimed and are trivially spoofed; trust only extends to the
|
||||||
every tablet shares one bucket.
|
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")
|
count = settings.trusted_proxy_count
|
||||||
if xff:
|
if count > 0:
|
||||||
first = xff.split(",")[0].strip()
|
xff = request.headers.get("x-forwarded-for")
|
||||||
if first:
|
if xff:
|
||||||
return first
|
hops = [h.strip() for h in xff.split(",") if h.strip()]
|
||||||
real = request.headers.get("x-real-ip")
|
if len(hops) >= count:
|
||||||
if real:
|
return hops[-count]
|
||||||
return real.strip()
|
# 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"
|
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]:
|
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
||||||
"""Remove timestamps outside the current sliding window."""
|
"""Remove timestamps outside the current sliding window."""
|
||||||
cutoff = now - self.WINDOW_SECONDS
|
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._login_requests, now)
|
||||||
self._evict_stale_ips(self._general_requests, now)
|
self._evict_stale_ips(self._general_requests, now)
|
||||||
|
|
||||||
# Check login-specific rate limit
|
# Check login-bucket rate limit (login itself, supervisor-credential
|
||||||
if path == self.LOGIN_PATH and request.method == "POST":
|
# paths, and the setup password)
|
||||||
|
if self._is_login_bucket_request(request.method, path):
|
||||||
allowed, retry_after = self._check_rate_limit(
|
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:
|
if not allowed:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -111,7 +176,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
# Check general rate limit
|
# Check general rate limit
|
||||||
allowed, retry_after = self._check_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:
|
if not allowed:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Authentication router - login, logout, profile."""
|
"""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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
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 (
|
from src.backend.models.api.user import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
PinLoginRequest,
|
||||||
|
PinUserItem,
|
||||||
UserProfileUpdate,
|
UserProfileUpdate,
|
||||||
UserResponse,
|
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"])
|
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)
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def logout(
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
all_sessions: bool = False,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Logout - invalidate API key."""
|
"""Logout. Deletes this session's API key only; ?all_sessions=true
|
||||||
await logout_user(db, user)
|
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)
|
@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 import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.config import settings
|
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.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
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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_PDF_TYPE = "application/pdf"
|
||||||
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
|
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:
|
def validate_file_type(content_type: str) -> bool:
|
||||||
"""Validate if file type is allowed."""
|
"""Validate if file type is allowed."""
|
||||||
@@ -119,25 +212,36 @@ async def upload_file(
|
|||||||
"""Upload an image or PDF file.
|
"""Upload an image or PDF file.
|
||||||
|
|
||||||
Files are stored in uploads/{recipe_id}/{version_id}/
|
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
|
# Read in chunks, stopping as soon as the size limit is crossed instead
|
||||||
if not validate_file_type(file.content_type):
|
# of buffering an oversized body into memory first.
|
||||||
raise HTTPException(
|
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
buffer = bytearray()
|
||||||
detail=f"File type {file.content_type} not allowed. Must be image or PDF.",
|
while True:
|
||||||
)
|
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
# Read file content
|
break
|
||||||
content = await file.read()
|
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)
|
file_size = len(content)
|
||||||
|
|
||||||
# Validate file size
|
# Validate file type from the actual bytes, not file.content_type
|
||||||
if not validate_file_size(file_size):
|
detected = detect_file_type(content)
|
||||||
|
if detected is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
# Determine storage path
|
||||||
if recipe_id is not None and version_id is not None:
|
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)
|
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 "")
|
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
|
# Generate unique filename if file already exists
|
||||||
file_path = storage_dir / safe_filename
|
file_path = storage_dir / safe_filename
|
||||||
counter = 1
|
counter = 1
|
||||||
while file_path.exists():
|
while file_path.exists():
|
||||||
name_parts = safe_filename.rsplit(".", 1)
|
file_path = storage_dir / f"{base_name}_{counter}{detected_ext}"
|
||||||
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}"
|
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
# Write file
|
# Write file
|
||||||
@@ -166,7 +269,7 @@ async def upload_file(
|
|||||||
|
|
||||||
# Generate thumbnail for images
|
# Generate thumbnail for images
|
||||||
thumbnail_path = None
|
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 = storage_dir / "thumbnails"
|
||||||
thumbnail_dir.mkdir(exist_ok=True)
|
thumbnail_dir.mkdir(exist_ok=True)
|
||||||
thumbnail_path = thumbnail_dir / file_path.name
|
thumbnail_path = thumbnail_dir / file_path.name
|
||||||
@@ -183,7 +286,7 @@ async def upload_file(
|
|||||||
return {
|
return {
|
||||||
"file_path": str(relative_path).replace("\\", "/"),
|
"file_path": str(relative_path).replace("\\", "/"),
|
||||||
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
|
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
|
||||||
"file_type": file.content_type,
|
"file_type": detected_mime,
|
||||||
"file_size": file_size,
|
"file_size": file_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,8 +298,14 @@ async def get_file(
|
|||||||
):
|
):
|
||||||
"""Serve a file from the uploads directory.
|
"""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)
|
full_path = resolve_upload_path(file_path)
|
||||||
return FileResponse(full_path)
|
return FileResponse(full_path)
|
||||||
|
|
||||||
@@ -205,13 +314,29 @@ async def get_file(
|
|||||||
async def delete_file(
|
async def delete_file(
|
||||||
file_path: str,
|
file_path: str,
|
||||||
user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Delete a file from the uploads directory.
|
"""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)
|
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
|
# Delete thumbnail if it exists
|
||||||
if full_path.parent.name != "thumbnails":
|
if full_path.parent.name != "thumbnails":
|
||||||
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
|
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
from datetime import datetime
|
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 import APIRouter, Depends, HTTPException, Query, status
|
||||||
from fastapi.responses import StreamingResponse
|
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.setting import SystemSetting
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.measurement import (
|
from src.backend.models.api.measurement import (
|
||||||
|
CurrentCycleResponse,
|
||||||
MeasurementAuthorisation,
|
MeasurementAuthorisation,
|
||||||
MeasurementBatchCreate,
|
MeasurementBatchCreate,
|
||||||
MeasurementCreate,
|
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 import auth_service, measurement_service
|
||||||
from src.backend.services.measurement_service import save_measurement
|
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"])
|
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||||
|
|
||||||
@@ -96,6 +100,24 @@ async def get_task_progress(
|
|||||||
return TaskProgressListResponse(tasks=tasks)
|
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)
|
@router.post("/{measurement_id}/authorise", response_model=MeasurementResponse)
|
||||||
async def authorise_measurement(
|
async def authorise_measurement(
|
||||||
measurement_id: int,
|
measurement_id: int,
|
||||||
@@ -125,6 +147,7 @@ async def authorise_measurement(
|
|||||||
|
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||||
)
|
)
|
||||||
measurement = await measurement_service.authorise_measurement(
|
measurement = await measurement_service.authorise_measurement(
|
||||||
db, measurement, supervisor,
|
db, measurement, supervisor,
|
||||||
@@ -173,6 +196,7 @@ def _build_measurement_filters(
|
|||||||
date_from: datetime | None,
|
date_from: datetime | None,
|
||||||
date_to: datetime | None,
|
date_to: datetime | None,
|
||||||
pass_fail: str | None,
|
pass_fail: str | None,
|
||||||
|
production_run_id: int | None = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
|
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
|
||||||
filters = []
|
filters = []
|
||||||
@@ -198,6 +222,8 @@ def _build_measurement_filters(
|
|||||||
filters.append(Measurement.measured_at <= date_to)
|
filters.append(Measurement.measured_at <= date_to)
|
||||||
if pass_fail is not None:
|
if pass_fail is not None:
|
||||||
filters.append(Measurement.pass_fail == pass_fail)
|
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
|
return filters
|
||||||
|
|
||||||
|
|
||||||
@@ -209,9 +235,10 @@ async def get_measurements(
|
|||||||
measured_by: int | None = Query(None),
|
measured_by: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
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),
|
page: int = Query(1, ge=1),
|
||||||
per_page: int = Query(50, ge=1, le=500),
|
per_page: int = Query(50, ge=1, le=500),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
@@ -227,7 +254,7 @@ async def get_measurements(
|
|||||||
"""
|
"""
|
||||||
filters = _build_measurement_filters(
|
filters = _build_measurement_filters(
|
||||||
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
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
|
# Build query
|
||||||
@@ -266,15 +293,18 @@ async def export_measurements_csv(
|
|||||||
measured_by: int | None = Query(None),
|
measured_by: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Export measurements to CSV with configurable delimiter and decimal separator."""
|
"""Export measurements to CSV with configurable delimiter and decimal separator."""
|
||||||
# Check user has MeasurementTec or Metrologist role
|
# Check user has MeasurementTec or Metrologist role, same as require_role: an
|
||||||
if not (user.has_role("MeasurementTec") or user.has_role("Metrologist")):
|
# 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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="MeasurementTec or Metrologist role required",
|
detail="MeasurementTec or Metrologist role required",
|
||||||
@@ -342,10 +372,10 @@ async def export_measurements_csv(
|
|||||||
value_str,
|
value_str,
|
||||||
m.pass_fail,
|
m.pass_fail,
|
||||||
deviation_str,
|
deviation_str,
|
||||||
m.lot_number or "",
|
neutralise_csv_cell(m.lot_number or ""),
|
||||||
m.serial_number or "",
|
neutralise_csv_cell(m.serial_number or ""),
|
||||||
m.input_method,
|
m.input_method,
|
||||||
m.measured_at.isoformat(),
|
iso_utc(m.measured_at),
|
||||||
m.synced_to_csv,
|
m.synced_to_csv,
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -355,6 +385,6 @@ async def export_measurements_csv(
|
|||||||
iter([output.getvalue()]),
|
iter([output.getvalue()]),
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
headers={
|
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
|
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.
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.api.middleware.api_key import get_current_user
|
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.production import ProductionRun
|
||||||
from src.backend.models.orm.user import User
|
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"])
|
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||||
|
|
||||||
_DERIVED = {
|
_DERIVED = {
|
||||||
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
|
"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:
|
async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunResponse:
|
||||||
"""The stored run plus everything the client cannot work out on its own."""
|
"""The stored run plus everything the client cannot work out on its own."""
|
||||||
return ProductionRunResponse(
|
return ProductionRunResponse(
|
||||||
@@ -37,13 +54,15 @@ async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunRes
|
|||||||
measurement_task_ids=await production_service.measurement_task_ids(
|
measurement_task_ids=await production_service.measurement_task_ids(
|
||||||
db, run.version_id,
|
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)
|
@router.post("", response_model=ProductionRunResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def open_production_run(
|
async def open_production_run(
|
||||||
data: ProductionRunCreate,
|
data: ProductionRunCreate,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(_require_run_operator),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Start a production at a station."""
|
"""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
|
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)
|
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
||||||
async def get_production_run(
|
async def get_production_run(
|
||||||
run_id: int,
|
run_id: int,
|
||||||
@@ -85,7 +125,7 @@ async def get_production_run(
|
|||||||
async def complete_measurement_cycle(
|
async def complete_measurement_cycle(
|
||||||
run_id: int,
|
run_id: int,
|
||||||
payload: CycleCompletePayload | None = None,
|
payload: CycleCompletePayload | None = None,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(_require_run_operator),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Record a finished measurement task; the last one restarts the interval."""
|
"""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(
|
async def remeasure_in_cycle(
|
||||||
run_id: int,
|
run_id: int,
|
||||||
payload: RemeasurePayload | None = None,
|
payload: RemeasurePayload | None = None,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(_require_run_operator),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Turn the piece over and measure again, without closing the cycle."""
|
"""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(
|
async def pause_production_run(
|
||||||
run_id: int,
|
run_id: int,
|
||||||
action: SupervisorAction,
|
action: SupervisorAction,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(_require_run_operator),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Fermo linea - requires a supervisor."""
|
"""Fermo linea - requires a supervisor."""
|
||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
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)
|
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
||||||
return await _as_response(db, run)
|
return await _as_response(db, run)
|
||||||
@@ -135,13 +176,14 @@ async def pause_production_run(
|
|||||||
async def resume_production_run(
|
async def resume_production_run(
|
||||||
run_id: int,
|
run_id: int,
|
||||||
action: SupervisorAction,
|
action: SupervisorAction,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(_require_run_operator),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Restart a stopped line - requires a supervisor."""
|
"""Restart a stopped line - requires a supervisor."""
|
||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
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)
|
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
||||||
return await _as_response(db, run)
|
return await _as_response(db, run)
|
||||||
@@ -151,13 +193,14 @@ async def resume_production_run(
|
|||||||
async def close_production_run(
|
async def close_production_run(
|
||||||
run_id: int,
|
run_id: int,
|
||||||
action: SupervisorAction,
|
action: SupervisorAction,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(_require_run_operator),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Fine produzione - requires a supervisor. Stops the timer for good."""
|
"""Fine produzione - requires a supervisor. Stops the timer for good."""
|
||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
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)
|
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
||||||
return await _as_response(db, run)
|
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.orm.user import User
|
||||||
from src.backend.models.api.recipe import (
|
from src.backend.models.api.recipe import (
|
||||||
RecipeCreate,
|
RecipeCreate,
|
||||||
|
RecipeDuplicate,
|
||||||
RecipeListResponse,
|
RecipeListResponse,
|
||||||
RecipeResponse,
|
RecipeResponse,
|
||||||
RecipeUpdate,
|
RecipeUpdate,
|
||||||
@@ -52,6 +53,18 @@ async def create_recipe(
|
|||||||
return _recipe_to_response(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)
|
@router.get("", response_model=RecipeListResponse)
|
||||||
async def list_recipes(
|
async def list_recipes(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -189,6 +202,11 @@ async def parse_technical_sheet(
|
|||||||
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
|
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(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:
|
except Exception as e:
|
||||||
logging.getLogger(__name__).error("AI parsing error: %s", e)
|
logging.getLogger(__name__).error("AI parsing error: %s", e)
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
|
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."""
|
"""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 import APIRouter, Depends, HTTPException, Query, status
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
@@ -18,11 +18,12 @@ async def download_spc_report(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -32,6 +33,7 @@ async def download_spc_report(
|
|||||||
db, recipe_id, subtask_id,
|
db, recipe_id, subtask_id,
|
||||||
version_id, date_from, date_to,
|
version_id, date_from, date_to,
|
||||||
operator_id, lot_number, serial_number,
|
operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -52,11 +54,12 @@ async def download_measurement_report(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int | None = Query(None),
|
subtask_id: int | None = Query(None),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -66,6 +69,7 @@ async def download_measurement_report(
|
|||||||
db, recipe_id, subtask_id,
|
db, recipe_id, subtask_id,
|
||||||
version_id, date_from, date_to,
|
version_id, date_from, date_to,
|
||||||
operator_id, lot_number, serial_number,
|
operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""System settings router - read/update settings, upload company logo."""
|
"""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 fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -8,11 +9,88 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
from src.backend.database import get_db
|
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.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.setting import SystemSetting
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
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("/")
|
@router.get("/")
|
||||||
async def get_settings(
|
async def get_settings(
|
||||||
@@ -49,6 +127,8 @@ async def update_settings(
|
|||||||
updated_keys = []
|
updated_keys = []
|
||||||
|
|
||||||
for key, value in settings_data.items():
|
for key, value in settings_data.items():
|
||||||
|
_validate_setting_value(key, value)
|
||||||
|
|
||||||
# Check if setting exists
|
# Check if setting exists
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SystemSetting).where(SystemSetting.setting_key == key)
|
select(SystemSetting).where(SystemSetting.setting_key == key)
|
||||||
@@ -88,35 +168,45 @@ async def upload_company_logo(
|
|||||||
"""Upload company logo.
|
"""Upload company logo.
|
||||||
|
|
||||||
Saves logo to uploads/logos/ and updates company_logo_path setting.
|
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)
|
# Read in chunks, stopping as soon as the size limit is crossed instead
|
||||||
allowed_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
|
# of buffering an oversized body into memory first.
|
||||||
if file.content_type not in allowed_types:
|
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||||
raise HTTPException(
|
buffer = bytearray()
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
chunk_size = 1024 * 1024
|
||||||
detail=f"File type {file.content_type} not allowed. Must be an image.",
|
while True:
|
||||||
)
|
chunk = await file.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
# Read file content
|
break
|
||||||
content = await file.read()
|
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)
|
file_size = len(content)
|
||||||
|
|
||||||
# Validate file size
|
# Validate file type from the actual bytes (SVG never matches: refused)
|
||||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
detected = detect_file_type(content)
|
||||||
if file_size > max_bytes:
|
if detected is None or detected[0] not in ALLOWED_IMAGE_TYPES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
# Create logos directory
|
||||||
logos_dir = settings.upload_path / "logos"
|
logos_dir = settings.upload_path / "logos"
|
||||||
logos_dir.mkdir(parents=True, exist_ok=True)
|
logos_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Use a fixed filename for the company logo
|
# Fixed filename for the company logo, extension forced to what was
|
||||||
file_extension = Path(file.filename).suffix
|
# actually detected (not what the client claimed or the filename said).
|
||||||
logo_filename = f"company_logo{file_extension}"
|
logo_filename = f"company_logo{detected_ext}"
|
||||||
logo_path = logos_dir / logo_filename
|
logo_path = logos_dir / logo_filename
|
||||||
|
|
||||||
# Write file
|
# Write file
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
|
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
|
||||||
NOT protected by API Key auth (standalone access).
|
NOT protected by API Key auth (standalone access).
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import hmac
|
||||||
import random
|
import random
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from src.backend.utils.tempo import iso_utc, utcnow
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -22,15 +26,29 @@ from src.backend.models.orm import (
|
|||||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
||||||
)
|
)
|
||||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
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
|
from src.backend.services.measurement_service import calculate_pass_fail
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
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))
|
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
|
# Request schemas
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -87,13 +105,57 @@ def _check_setup_enabled() -> None:
|
|||||||
def _check_password(password: str) -> None:
|
def _check_password(password: str) -> None:
|
||||||
"""Verify the provided password matches the setup password."""
|
"""Verify the provided password matches the setup password."""
|
||||||
_check_setup_enabled()
|
_check_setup_enabled()
|
||||||
if password != settings.setup_password:
|
if not hmac.compare_digest(password, settings.setup_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Invalid setup password",
|
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(
|
def _generate_measurements_for_subtask(
|
||||||
subtask: RecipeSubtask,
|
subtask: RecipeSubtask,
|
||||||
version_id: int,
|
version_id: int,
|
||||||
@@ -220,14 +282,24 @@ async def setup_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def setup_status():
|
async def setup_status(password: str | None = None):
|
||||||
"""Return database table status (no password required, but setup must be enabled)."""
|
"""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()
|
_check_setup_enabled()
|
||||||
|
initialised = await _admin_exists()
|
||||||
|
if not _password_matches(password):
|
||||||
|
return {"initialised": initialised}
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
table_names: list[str] = await conn.run_sync(
|
table_names: list[str] = await conn.run_sync(
|
||||||
lambda sync_conn: sa_inspect(sync_conn).get_table_names()
|
lambda sync_conn: sa_inspect(sync_conn).get_table_names()
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
"initialised": initialised,
|
||||||
"tables_exist": len(table_names) > 0,
|
"tables_exist": len(table_names) > 0,
|
||||||
"table_count": len(table_names),
|
"table_count": len(table_names),
|
||||||
"tables": table_names,
|
"tables": table_names,
|
||||||
@@ -238,8 +310,12 @@ async def setup_status():
|
|||||||
async def init_db(body: PasswordBody):
|
async def init_db(body: PasswordBody):
|
||||||
"""Create all database tables."""
|
"""Create all database tables."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
await _require_not_initialised()
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_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 tables created successfully"}
|
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."""
|
"""Create an admin user with all roles."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
|
||||||
api_key = secrets.token_hex(32)
|
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
user = User(
|
user = User(
|
||||||
@@ -259,9 +333,10 @@ async def create_admin(body: CreateAdminBody):
|
|||||||
display_name=body.admin_display_name,
|
display_name=body.admin_display_name,
|
||||||
roles=["Maker", "MeasurementTec", "Metrologist"],
|
roles=["Maker", "MeasurementTec", "Metrologist"],
|
||||||
is_admin=True,
|
is_admin=True,
|
||||||
api_key=api_key,
|
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
api_key = await issue_api_key(session, user)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -274,6 +349,7 @@ async def create_admin(body: CreateAdminBody):
|
|||||||
async def seed_demo_data(body: PasswordBody):
|
async def seed_demo_data(body: PasswordBody):
|
||||||
"""Seed database with demo users and a sample recipe."""
|
"""Seed database with demo users and a sample recipe."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
await _require_not_initialised()
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
@@ -330,7 +406,6 @@ async def seed_demo_data(body: PasswordBody):
|
|||||||
email=u["email"],
|
email=u["email"],
|
||||||
roles=u["roles"],
|
roles=u["roles"],
|
||||||
is_admin=u["is_admin"],
|
is_admin=u["is_admin"],
|
||||||
api_key=secrets.token_hex(32),
|
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
created_users.append(user)
|
created_users.append(user)
|
||||||
@@ -484,7 +559,7 @@ async def seed_demo_data(body: PasswordBody):
|
|||||||
tec_user = created_users[2] # tec1
|
tec_user = created_users[2] # tec1
|
||||||
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
|
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)]
|
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
|
all_subtasks = subtasks_1 + subtasks_2
|
||||||
measurements_created = 0
|
measurements_created = 0
|
||||||
@@ -532,8 +607,8 @@ async def list_users(body: PasswordBody):
|
|||||||
"roles": u.roles or [],
|
"roles": u.roles or [],
|
||||||
"is_admin": u.is_admin,
|
"is_admin": u.is_admin,
|
||||||
"active": u.active,
|
"active": u.active,
|
||||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
"created_at": iso_utc(u.created_at) or None,
|
||||||
"last_login": u.last_login.isoformat() if u.last_login else None,
|
"last_login": iso_utc(u.last_login) or None,
|
||||||
}
|
}
|
||||||
for u in users
|
for u in users
|
||||||
],
|
],
|
||||||
@@ -544,6 +619,7 @@ async def list_users(body: PasswordBody):
|
|||||||
async def manage_user(body: ManageUserBody):
|
async def manage_user(body: ManageUserBody):
|
||||||
"""Create or update a user. user_id=null creates new, user_id=int updates."""
|
"""Create or update a user. user_id=null creates new, user_id=int updates."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
await _require_not_initialised()
|
||||||
|
|
||||||
invalid = set(body.roles) - VALID_ROLES
|
invalid = set(body.roles) - VALID_ROLES
|
||||||
if invalid:
|
if invalid:
|
||||||
@@ -577,7 +653,6 @@ async def manage_user(body: ManageUserBody):
|
|||||||
email=body.email,
|
email=body.email,
|
||||||
roles=body.roles,
|
roles=body.roles,
|
||||||
is_admin=body.is_admin,
|
is_admin=body.is_admin,
|
||||||
api_key=secrets.token_hex(32),
|
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -618,6 +693,7 @@ async def manage_user(body: ManageUserBody):
|
|||||||
async def change_user_password(body: ChangeUserPasswordBody):
|
async def change_user_password(body: ChangeUserPasswordBody):
|
||||||
"""Change a user's password and invalidate their API key."""
|
"""Change a user's password and invalidate their API key."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
await _require_not_initialised()
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
@@ -631,7 +707,7 @@ async def change_user_password(body: ChangeUserPasswordBody):
|
|||||||
detail=f"User ID {body.user_id} not found",
|
detail=f"User ID {body.user_id} not found",
|
||||||
)
|
)
|
||||||
user.password_hash = hash_password(body.new_password)
|
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}'"}
|
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
|
user.active = not user.active
|
||||||
if 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"
|
new_status = "active" if user.active else "inactive"
|
||||||
|
|
||||||
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
|
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):
|
async def reset_db(body: ResetDbBody):
|
||||||
"""Drop all tables and recreate them. Requires confirm=true."""
|
"""Drop all tables and recreate them. Requires confirm=true."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
await _require_not_initialised()
|
||||||
|
|
||||||
if not body.confirm:
|
if not body.confirm:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -674,5 +751,8 @@ async def reset_db(body: ResetDbBody):
|
|||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
await conn.run_sync(Base.metadata.create_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"}
|
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
|
# The int-typed station_id param already guards against "by-code" being
|
||||||
# matched as a station id, but keeping the explicit order avoids surprises
|
# matched as a station id, but keeping the explicit order avoids surprises
|
||||||
# during refactors (e.g. if someone regroups handlers by HTTP method).
|
# 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])
|
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
|
||||||
async def list_recipes_by_station_code(
|
async def list_recipes_by_station_code(
|
||||||
code: str,
|
code: str,
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"""Statistics router - SPC endpoints for Metrologist dashboard."""
|
"""Statistics router - SPC endpoints for Metrologist dashboard."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from src.backend.models.api.tipi import UtcNaiveInput
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.api.middleware.api_key import require_metrologist
|
from src.backend.api.middleware.api_key import require_metrologist
|
||||||
from src.backend.models.orm.measurement import Measurement
|
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.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
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.orm.user import User
|
||||||
from src.backend.models.api.statistics import (
|
from src.backend.models.api.statistics import (
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
@@ -37,6 +40,7 @@ async def _query_measurements(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None, # #58: solo le misure di una sessione
|
||||||
) -> list[Measurement]:
|
) -> list[Measurement]:
|
||||||
"""Query measurements with common filters.
|
"""Query measurements with common filters.
|
||||||
|
|
||||||
@@ -66,6 +70,9 @@ async def _query_measurements(
|
|||||||
if serial_number is not None:
|
if serial_number is not None:
|
||||||
filters.append(Measurement.serial_number == serial_number)
|
filters.append(Measurement.serial_number == serial_number)
|
||||||
|
|
||||||
|
if production_run_id is not None:
|
||||||
|
filters.append(Measurement.production_run_id == production_run_id)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Measurement)
|
select(Measurement)
|
||||||
.where(and_(*filters) if filters else True)
|
.where(and_(*filters) if filters else True)
|
||||||
@@ -100,11 +107,12 @@ async def get_summary(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
subtask_id: int | None = Query(None),
|
subtask_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> SummaryData:
|
) -> SummaryData:
|
||||||
@@ -112,6 +120,7 @@ async def get_summary(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
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]
|
pass_fail_values = [m.pass_fail for m in measurements]
|
||||||
return compute_summary(pass_fail_values)
|
return compute_summary(pass_fail_values)
|
||||||
@@ -122,11 +131,12 @@ async def get_capability(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> CapabilityData:
|
) -> CapabilityData:
|
||||||
@@ -134,6 +144,7 @@ async def get_capability(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
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]
|
values = [float(m.value) for m in measurements]
|
||||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||||
@@ -145,11 +156,12 @@ async def get_control_chart(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> ControlChartData:
|
) -> ControlChartData:
|
||||||
@@ -157,6 +169,7 @@ async def get_control_chart(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
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]
|
values = [float(m.value) for m in measurements]
|
||||||
timestamps = [m.measured_at for m in measurements]
|
timestamps = [m.measured_at for m in measurements]
|
||||||
@@ -172,11 +185,12 @@ async def get_histogram(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_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),
|
n_bins: int = Query(20, ge=5, le=100),
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -185,6 +199,7 @@ async def get_histogram(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
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]
|
values = [float(m.value) for m in measurements]
|
||||||
return compute_histogram(values, n_bins)
|
return compute_histogram(values, n_bins)
|
||||||
@@ -234,3 +249,122 @@ async def get_recipe_subtasks(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return subtasks_list
|
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,
|
TaskReorderRequest,
|
||||||
TaskResponse,
|
TaskResponse,
|
||||||
TaskUpdate,
|
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"])
|
router = APIRouter(tags=["tasks"])
|
||||||
|
|
||||||
@@ -99,6 +104,64 @@ async def _ensure_version_is_current(db: AsyncSession, version_id: int) -> Recip
|
|||||||
return version
|
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
|
# Task endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -142,6 +205,13 @@ async def create_task(
|
|||||||
This creates a NEW version via copy-on-write, then appends the task to
|
This creates a NEW version via copy-on-write, then appends the task to
|
||||||
the new version. Requires Maker role.
|
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
|
# Ensure recipe exists and is active
|
||||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||||
recipe = result.scalar_one_or_none()
|
recipe = result.scalar_one_or_none()
|
||||||
@@ -179,6 +249,11 @@ async def create_task(
|
|||||||
file_path=data.file_path,
|
file_path=data.file_path,
|
||||||
file_type=data.file_type,
|
file_type=data.file_type,
|
||||||
annotations_json=data.annotations_json,
|
annotations_json=data.annotations_json,
|
||||||
|
vision_json=data.vision_json,
|
||||||
|
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
|
||||||
|
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
|
||||||
|
at_close=data.at_close,
|
||||||
|
fields=fields,
|
||||||
)
|
)
|
||||||
db.add(new_task)
|
db.add(new_task)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -197,6 +272,7 @@ async def create_task(
|
|||||||
ltl=sub_data.ltl,
|
ltl=sub_data.ltl,
|
||||||
unit=sub_data.unit,
|
unit=sub_data.unit,
|
||||||
image_path=sub_data.image_path,
|
image_path=sub_data.image_path,
|
||||||
|
vision_output=sub_data.vision_output,
|
||||||
)
|
)
|
||||||
db.add(sub)
|
db.add(sub)
|
||||||
|
|
||||||
@@ -262,10 +338,16 @@ async def reorder_tasks(
|
|||||||
async def update_task(
|
async def update_task(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
data: TaskUpdate,
|
data: TaskUpdate,
|
||||||
_user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
task = await _get_task_or_404(db, task_id)
|
||||||
await _ensure_version_is_current(db, task.version_id)
|
await _ensure_version_is_current(db, task.version_id)
|
||||||
|
|
||||||
@@ -273,6 +355,28 @@ async def update_task(
|
|||||||
if not update_data:
|
if not update_data:
|
||||||
return TaskResponse.model_validate(task)
|
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():
|
for field, value in update_data.items():
|
||||||
setattr(task, field, value)
|
setattr(task, field, value)
|
||||||
|
|
||||||
@@ -284,18 +388,37 @@ async def update_task(
|
|||||||
@router.delete("/api/tasks/{task_id}", status_code=204)
|
@router.delete("/api/tasks/{task_id}", status_code=204)
|
||||||
async def delete_task(
|
async def delete_task(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
_user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Delete a task and its subtasks. The task must belong to the current version.
|
"""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)
|
task = await _get_task_or_404(db, task_id)
|
||||||
await _ensure_version_is_current(db, task.version_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.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)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -310,16 +433,19 @@ async def delete_task(
|
|||||||
async def create_subtask(
|
async def create_subtask(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
data: SubtaskCreate,
|
data: SubtaskCreate,
|
||||||
_user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Add a subtask to a task. The task must belong to the current version.
|
"""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)
|
task = await _get_task_or_404(db, task_id)
|
||||||
await _ensure_version_is_current(db, task.version_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
|
# Check marker_number uniqueness within the task
|
||||||
existing_markers = {s.marker_number for s in task.subtasks}
|
existing_markers = {s.marker_number for s in task.subtasks}
|
||||||
if data.marker_number in existing_markers:
|
if data.marker_number in existing_markers:
|
||||||
@@ -329,7 +455,7 @@ async def create_subtask(
|
|||||||
)
|
)
|
||||||
|
|
||||||
subtask = RecipeSubtask(
|
subtask = RecipeSubtask(
|
||||||
task_id=task_id,
|
task_id=task.id,
|
||||||
marker_number=data.marker_number,
|
marker_number=data.marker_number,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
measurement_type=data.measurement_type,
|
measurement_type=data.measurement_type,
|
||||||
@@ -340,6 +466,7 @@ async def create_subtask(
|
|||||||
ltl=data.ltl,
|
ltl=data.ltl,
|
||||||
unit=data.unit,
|
unit=data.unit,
|
||||||
image_path=data.image_path,
|
image_path=data.image_path,
|
||||||
|
vision_output=data.vision_output,
|
||||||
)
|
)
|
||||||
db.add(subtask)
|
db.add(subtask)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -351,12 +478,13 @@ async def create_subtask(
|
|||||||
async def update_subtask(
|
async def update_subtask(
|
||||||
subtask_id: int,
|
subtask_id: int,
|
||||||
data: SubtaskUpdate,
|
data: SubtaskUpdate,
|
||||||
_user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update a subtask. Its parent task must belong to the current version.
|
"""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)
|
subtask = await _get_subtask_or_404(db, subtask_id)
|
||||||
|
|
||||||
@@ -368,6 +496,24 @@ async def update_subtask(
|
|||||||
if not update_data:
|
if not update_data:
|
||||||
return SubtaskResponse.model_validate(subtask)
|
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():
|
for field, value in update_data.items():
|
||||||
setattr(subtask, field, value)
|
setattr(subtask, field, value)
|
||||||
|
|
||||||
@@ -379,12 +525,13 @@ async def update_subtask(
|
|||||||
@router.delete("/api/subtasks/{subtask_id}", status_code=204)
|
@router.delete("/api/subtasks/{subtask_id}", status_code=204)
|
||||||
async def delete_subtask(
|
async def delete_subtask(
|
||||||
subtask_id: int,
|
subtask_id: int,
|
||||||
_user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Delete a subtask. Its parent task must belong to the current version.
|
"""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)
|
subtask = await _get_subtask_or_404(db, subtask_id)
|
||||||
|
|
||||||
@@ -392,5 +539,7 @@ async def delete_subtask(
|
|||||||
task = await _get_task_or_404(db, subtask.task_id)
|
task = await _get_task_or_404(db, subtask.task_id)
|
||||||
await _ensure_version_is_current(db, task.version_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.delete(subtask)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
"""Users router - CRUD operations (admin only)."""
|
"""Users router - CRUD operations (admin only)."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.api.middleware.api_key import require_admin_user
|
from src.backend.api.middleware.api_key import require_admin_user
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
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
|
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"])
|
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")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
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
|
# Validate roles if provided
|
||||||
if "roles" in update_data:
|
if "roles" in update_data:
|
||||||
for role in update_data["roles"]:
|
for role in update_data["roles"]:
|
||||||
@@ -86,6 +107,27 @@ async def update_user(
|
|||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
|
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():
|
for field, value in update_data.items():
|
||||||
setattr(user, field, value)
|
setattr(user, field, value)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -100,16 +142,45 @@ async def change_user_password(
|
|||||||
admin: User = Depends(require_admin_user),
|
admin: User = Depends(require_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
user.password_hash = hash_password(data.password)
|
user.password_hash = hash_password(data.password)
|
||||||
|
await revoke_all_keys(db, user.id)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"message": f"Password changed for user {user.username}"}
|
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)
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def deactivate_user(
|
async def deactivate_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -127,7 +198,7 @@ async def deactivate_user(
|
|||||||
detail="Cannot deactivate yourself",
|
detail="Cannot deactivate yourself",
|
||||||
)
|
)
|
||||||
user.active = False
|
user.active = False
|
||||||
user.api_key = None
|
await revoke_all_keys(db, user.id)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
@@ -142,5 +213,5 @@ async def regenerate_user_key(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
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}"}
|
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""The client asks for a measurement and gets an outcome.
|
||||||
|
|
||||||
|
It does not know, and must not know, whether the server or the station computed
|
||||||
|
the numbers: that is what lets a station's configuration change without touching
|
||||||
|
the frontend or the recipes.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
|
||||||
|
from src.backend.api.routers.files import (
|
||||||
|
ALLOWED_IMAGE_TYPES, 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, 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
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
||||||
|
|
||||||
|
# A vision graph measures pixels, not a PDF: stricter than files.py's
|
||||||
|
# ALLOWED_TYPES, which also accepts documents for technical drawings.
|
||||||
|
_IMAGE_EXTENSIONS = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/webp": "webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sniffed from the first bytes, independent of the declared Content-Type: a
|
||||||
|
# text file uploaded as "image/png" passes the header check above and must
|
||||||
|
# still be caught before it reaches the worker as a bare 500 (#15). BMP and
|
||||||
|
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
|
||||||
|
# because PIL - and so the worker - can decode both; this check only refuses
|
||||||
|
# what is not a real image at all, it does not narrow the allow-list above.
|
||||||
|
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
|
||||||
|
(b"\x89PNG\r\n\x1a\n",), # PNG
|
||||||
|
(b"\xff\xd8\xff",), # JPEG
|
||||||
|
(b"BM",), # BMP
|
||||||
|
(b"II*\x00", b"MM\x00*"), # TIFF, little- and big-endian
|
||||||
|
(b"GIF87a", b"GIF89a"), # GIF
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_an_image(content: bytes) -> bool:
|
||||||
|
"""True if `content` starts with a known image format's magic bytes."""
|
||||||
|
for signatures in _IMAGE_SIGNATURES:
|
||||||
|
if any(content.startswith(sig) for sig in signatures):
|
||||||
|
return True
|
||||||
|
# WebP: "RIFF" + 4-byte size + "WEBP", the size in between is not fixed.
|
||||||
|
return len(content) >= 12 and content[:4] == b"RIFF" and content[8:12] == b"WEBP"
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
|
||||||
|
"""Shared by the endpoints of this router."""
|
||||||
|
task = (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == task_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_image(image: UploadFile) -> bytes:
|
||||||
|
"""Type checked from the header before reading, size checked once the body
|
||||||
|
is in hand - the same two-step convention `files.py::upload_file` already
|
||||||
|
uses, reused rather than reinvented here.
|
||||||
|
"""
|
||||||
|
if image.content_type not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
||||||
|
)
|
||||||
|
content = await image.read()
|
||||||
|
if not validate_file_size(len(content)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"File size {len(content)} bytes exceeds maximum "
|
||||||
|
f"{settings.max_upload_size_mb}MB"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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, 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, report = await vision_service.execute_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
await _read_image(image),
|
||||||
|
current_user.id,
|
||||||
|
version_id=task.version_id,
|
||||||
|
lot_number=lot_number,
|
||||||
|
serial_number=serial_number,
|
||||||
|
production_run_id=production_run_id,
|
||||||
|
image_ext=_IMAGE_EXTENSIONS.get(image.content_type, "png"),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return VisionExecuteResponse(
|
||||||
|
vision_result_id=result.id,
|
||||||
|
engine_version=result.engine_version,
|
||||||
|
measurements=[
|
||||||
|
VisionMeasurementResult(
|
||||||
|
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
|
||||||
|
)
|
||||||
|
for m in saved
|
||||||
|
],
|
||||||
|
outcome=report.get("outcome"),
|
||||||
|
overlay=report.get("overlay") or [],
|
||||||
|
image_url=f"/api/vision/results/{result.id}/image",
|
||||||
|
failures=report.get("failures") or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/results/{result_id}/image")
|
||||||
|
async def get_result_image(
|
||||||
|
result_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_measurement_tec),
|
||||||
|
) -> FileResponse:
|
||||||
|
"""The image a vision run measured, as saved by `execute` (#46)."""
|
||||||
|
result = (await db.execute(
|
||||||
|
select(VisionResult).where(VisionResult.id == result_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if result is None or not result.image_path:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Result image not found",
|
||||||
|
)
|
||||||
|
return FileResponse(resolve_upload_path(result.image_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _422(errors: list[str]) -> HTTPException:
|
||||||
|
# detail e' una lista di stringhe: il frontend legge i 422 solo da `detail`.
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
||||||
|
async def upload_recipe(
|
||||||
|
task_id: int,
|
||||||
|
recipe: UploadFile = File(...),
|
||||||
|
files: list[UploadFile] = File(default=[]),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> VisionRecipeUploadResponse:
|
||||||
|
"""Load a VisionSuite v2 recipe plus its meta files onto a task (#46)."""
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
if task.task_type not in vision_service.VISION_TASK_TYPES:
|
||||||
|
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
|
||||||
|
|
||||||
|
raw = await recipe.read()
|
||||||
|
if not validate_file_size(len(raw)):
|
||||||
|
raise _422([f"recipe exceeds {settings.max_upload_size_mb}MB"])
|
||||||
|
try:
|
||||||
|
graph = json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise _422(["the recipe is not valid JSON"])
|
||||||
|
if not isinstance(graph, dict):
|
||||||
|
raise _422(["the recipe must be a JSON object"])
|
||||||
|
|
||||||
|
uploads: list[tuple[str, bytes]] = []
|
||||||
|
for f in files:
|
||||||
|
content = await f.read()
|
||||||
|
if not validate_file_size(len(content)):
|
||||||
|
raise _422([f"file {f.filename!r} exceeds {settings.max_upload_size_mb}MB"])
|
||||||
|
uploads.append((f.filename or "", content))
|
||||||
|
placed, errors = vision_service.match_recipe_files(graph, uploads)
|
||||||
|
if errors:
|
||||||
|
raise _422(errors)
|
||||||
|
|
||||||
|
# The folder is content-addressed and immutable; writing it before the
|
||||||
|
# validation is harmless (an orphan folder is never referenced) and the
|
||||||
|
# worker needs it on disk to validate meta files.
|
||||||
|
try:
|
||||||
|
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _422([str(exc)])
|
||||||
|
check = await vision_service.validate_recipe(graph, recipe_dir)
|
||||||
|
if not check["ok"]:
|
||||||
|
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
||||||
|
|
||||||
|
task.vision_json = graph
|
||||||
|
task.vision_recipe_dir = recipe_dir
|
||||||
|
await db.commit()
|
||||||
|
return VisionRecipeUploadResponse(
|
||||||
|
task_id=task.id,
|
||||||
|
recipe_dir=recipe_dir,
|
||||||
|
files=vision_service.list_recipe_files(recipe_dir),
|
||||||
|
outputs=check["outputs"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/recipe", response_model=VisionRecipeResponse)
|
||||||
|
async def get_recipe(
|
||||||
|
task_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> VisionRecipeResponse:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
outputs: list[dict] = []
|
||||||
|
if task.vision_json:
|
||||||
|
check = await vision_service.validate_recipe(
|
||||||
|
task.vision_json, task.vision_recipe_dir,
|
||||||
|
)
|
||||||
|
outputs = check.get("outputs", [])
|
||||||
|
return VisionRecipeResponse(
|
||||||
|
task_id=task.id,
|
||||||
|
recipe_dir=task.vision_recipe_dir,
|
||||||
|
recipe=task.vision_json,
|
||||||
|
files=vision_service.list_recipe_files(task.vision_recipe_dir),
|
||||||
|
outputs=outputs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Composing a recipe and keeping a reference image are Maker operations, not
|
||||||
|
# measuring: every endpoint below gates on `require_maker`, not the
|
||||||
|
# `require_measurement_tec` that `execute` above uses.
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reference-images")
|
||||||
|
async def add_reference_image(
|
||||||
|
task_id: int = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
note: str | None = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
content = await _read_image(image)
|
||||||
|
reference = await vision_service.save_reference_image(
|
||||||
|
db, task.id, content, note=note,
|
||||||
|
extension=_IMAGE_EXTENSIONS[image.content_type],
|
||||||
|
)
|
||||||
|
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(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
return await vision_service.preview(db, task, await _read_image(image))
|
||||||
+79
-4
@@ -1,23 +1,58 @@
|
|||||||
"""TieMeasureFlow Server Configuration."""
|
"""TieMeasureFlow Server Configuration."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings
|
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):
|
class Settings(BaseSettings):
|
||||||
"""Application settings loaded from environment variables."""
|
"""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
|
# Database
|
||||||
db_host: str = "localhost"
|
db_host: str = "localhost"
|
||||||
db_port: int = 3306
|
db_port: int = 3306
|
||||||
db_name: str = "tiemeasureflow"
|
db_name: str = "tiemeasureflow"
|
||||||
db_user: str = "tmflow"
|
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
|
||||||
server_host: str = "0.0.0.0"
|
server_host: str = "0.0.0.0"
|
||||||
server_port: int = 8000
|
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"
|
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
|
# File Storage
|
||||||
upload_dir: str = "uploads"
|
upload_dir: str = "uploads"
|
||||||
@@ -33,14 +68,23 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Setup page (empty = disabled)
|
# Setup page (empty = disabled)
|
||||||
setup_password: str | None = None
|
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)
|
# AI / OpenRouter (for technical sheet parsing)
|
||||||
openrouter_api_key: str | None = None
|
openrouter_api_key: str | None = None
|
||||||
openrouter_model: str = "anthropic/claude-sonnet-4"
|
openrouter_model: str = "anthropic/claude-sonnet-4"
|
||||||
|
|
||||||
|
# Vision worker (internal network only, reachable as `vision` in Compose)
|
||||||
|
vision_worker_url: str = "http://vision:8100"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
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 (
|
return (
|
||||||
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
||||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||||
@@ -59,7 +103,12 @@ class Settings(BaseSettings):
|
|||||||
(mounted as a Docker volume), not inside the backend tree.
|
(mounted as a Docker volume), not inside the backend tree.
|
||||||
"""
|
"""
|
||||||
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
# 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
|
# Always resolve .env against the project root regardless of cwd
|
||||||
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
||||||
@@ -69,5 +118,31 @@ class Settings(BaseSettings):
|
|||||||
"extra": "ignore",
|
"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()
|
settings = Settings()
|
||||||
|
|||||||
+25
-8
@@ -1,6 +1,7 @@
|
|||||||
"""Async SQLAlchemy database engine and session management."""
|
"""Async SQLAlchemy database engine and session management."""
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
@@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
|
|
||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
# Create async engine
|
_IS_SQLITE = settings.database_url.startswith("sqlite")
|
||||||
engine = create_async_engine(
|
|
||||||
settings.database_url,
|
# Create async engine. MySQL gets a connection pool; SQLite (local file of the
|
||||||
echo=False,
|
# Windows package) does not accept pool_size/max_overflow/pool_recycle.
|
||||||
pool_size=10,
|
if _IS_SQLITE:
|
||||||
max_overflow=20,
|
engine = create_async_engine(settings.database_url, echo=False)
|
||||||
pool_recycle=3600,
|
|
||||||
)
|
@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
|
# Session factory
|
||||||
async_session_factory = async_sessionmaker(
|
async_session_factory = async_sessionmaker(
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ from src.backend.api.routers.statistics import router as statistics_router
|
|||||||
from src.backend.api.routers.setup import router as setup_router
|
from src.backend.api.routers.setup import router as setup_router
|
||||||
from src.backend.api.routers.stations import router as stations_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.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
|
@asynccontextmanager
|
||||||
@@ -75,6 +77,8 @@ app.include_router(reports_router)
|
|||||||
app.include_router(setup_router)
|
app.include_router(setup_router)
|
||||||
app.include_router(stations_router)
|
app.include_router(stations_router)
|
||||||
app.include_router(production_router)
|
app.include_router(production_router)
|
||||||
|
app.include_router(vision_router)
|
||||||
|
app.include_router(task_results_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@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 identifiers, used by Alembic.
|
||||||
revision: str = '001_image_path'
|
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
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""the vision graph lives on the task, and the quote names its output
|
||||||
|
|
||||||
|
`xf_compare` was a typo for `dxf_compare`. No row uses either value yet, so
|
||||||
|
renaming costs one statement here; from the first saved task onwards it would
|
||||||
|
be a data migration and the typo would already be in the public API.
|
||||||
|
|
||||||
|
Revision ID: 011_vision_graph
|
||||||
|
Revises: 010_meas_authorisation
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '011_vision_graph'
|
||||||
|
down_revision: Union[str, None] = '010_meas_authorisation'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
|
||||||
|
NEW_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.add_column(sa.Column('vision_json', sa.JSON(), nullable=True))
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('recipe_subtasks') as batch:
|
||||||
|
batch.add_column(
|
||||||
|
sa.Column('vision_output', sa.String(length=120), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_subtasks') as batch:
|
||||||
|
batch.drop_column('vision_output')
|
||||||
|
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
batch.drop_column('vision_json')
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""one row per vision execution, and camera as an input method
|
||||||
|
|
||||||
|
The vision surroundings do not go on `measurements`: statistics and the export
|
||||||
|
read that table on every pass. And it would be wrong modelling anyway - one
|
||||||
|
acquisition produces N quotes, and image, overlay, device and engine version are
|
||||||
|
the same for all of them.
|
||||||
|
|
||||||
|
Revision ID: 012_vision_results
|
||||||
|
Revises: 011_vision_graph
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '012_vision_results'
|
||||||
|
down_revision: Union[str, None] = '011_vision_graph'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_METHODS = ('usb_caliper', 'manual')
|
||||||
|
NEW_METHODS = ('usb_caliper', 'manual', 'camera')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'vision_results',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
||||||
|
sa.Column('image_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('overlay_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('engine_version', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('executed_on',
|
||||||
|
sa.Enum('server', 'station', name='vision_executed_on_enum'),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column('station_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('stations.id'), nullable=True),
|
||||||
|
sa.Column('device_code', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('graph_snapshot', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('duration_ms', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('executed_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'input_method',
|
||||||
|
existing_type=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
||||||
|
type_=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
batch.add_column(
|
||||||
|
sa.Column('vision_result_id', sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
batch.create_foreign_key(
|
||||||
|
'fk_measurements_vision_result',
|
||||||
|
'vision_results', ['vision_result_id'], ['id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.drop_constraint('fk_measurements_vision_result',
|
||||||
|
type_='foreignkey')
|
||||||
|
batch.drop_column('vision_result_id')
|
||||||
|
batch.alter_column(
|
||||||
|
'input_method',
|
||||||
|
existing_type=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
||||||
|
type_=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_table('vision_results')
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""reference images, with their provenance
|
||||||
|
|
||||||
|
A reference image without provenance is a trap: the graph is re-run months later,
|
||||||
|
different numbers come out, and there is no way to tell an updated engine from a
|
||||||
|
moved lens.
|
||||||
|
|
||||||
|
`expected_json` holds what the graph is supposed to produce on this image. It is
|
||||||
|
what makes a regression run possible after a VisionSuite upgrade - the safety net
|
||||||
|
that makes bumping the submodule sustainable with an engine on two hosts.
|
||||||
|
|
||||||
|
Revision ID: 013_reference_images
|
||||||
|
Revises: 012_vision_results
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '013_reference_images'
|
||||||
|
down_revision: Union[str, None] = '012_vision_results'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'vision_reference_images',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
||||||
|
sa.Column('path', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('station_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('stations.id'), nullable=True),
|
||||||
|
sa.Column('device_code', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('engine_version', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('note', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('expected_json', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('acquired_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('vision_reference_images')
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""vision results and reference images cascade with their task
|
||||||
|
|
||||||
|
Both `vision_results.task_id` and `vision_reference_images.task_id` were
|
||||||
|
created (012, 013) with a plain, unnamed foreign key - no ON DELETE clause,
|
||||||
|
so deleting a task with any camera history or reference image attached fails
|
||||||
|
on a strict FK check (MySQL) instead of taking the vision rows with it, the
|
||||||
|
way `production_events.run_id` already does for a closed production run.
|
||||||
|
|
||||||
|
The original constraint has no name to drop by (SQLAlchemy leaves inline
|
||||||
|
`ForeignKey(...)` unnamed unless told otherwise, and MySQL then assigns its
|
||||||
|
own `<table>_ibfk_N`, which is not portable to reflect on). `sa.inspect`
|
||||||
|
looks it up at migration time instead of guessing it - the same way on
|
||||||
|
MySQL and on SQLite (batch mode), where an anonymous FK reflects with
|
||||||
|
`name=None` and there is nothing to drop before adding the new one.
|
||||||
|
|
||||||
|
Reference images on disk are not touched here - deleting a task's row does
|
||||||
|
not, on its own, know to also delete the file next to it; that gap is
|
||||||
|
declared open in issue #38 point 6's write-up (`vision_service` has no
|
||||||
|
single point today that intercepts a task deletion).
|
||||||
|
|
||||||
|
Revision ID: 014_vision_fk_ondelete
|
||||||
|
Revises: 013_reference_images
|
||||||
|
Create Date: 2026-09-21
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '014_vision_fk_ondelete'
|
||||||
|
down_revision: Union[str, None] = '013_reference_images'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_NEW_FK_NAMES = {
|
||||||
|
'vision_results': 'fk_vision_results_task_id',
|
||||||
|
'vision_reference_images': 'fk_vision_reference_images_task_id',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_task_id_fk_name(bind, table_name: str) -> Union[str, None]:
|
||||||
|
"""The name reflection gives the pre-existing `task_id` FK, if any.
|
||||||
|
|
||||||
|
None on SQLite for a constraint that was never named - batch mode then
|
||||||
|
has nothing to drop, and just adds the new named one alongside it.
|
||||||
|
"""
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
for fk in inspector.get_foreign_keys(table_name):
|
||||||
|
if fk.get("constrained_columns") == ["task_id"]:
|
||||||
|
return fk.get("name")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
for table_name, new_name in _NEW_FK_NAMES.items():
|
||||||
|
existing_name = _existing_task_id_fk_name(bind, table_name)
|
||||||
|
with op.batch_alter_table(table_name) as batch:
|
||||||
|
if existing_name:
|
||||||
|
batch.drop_constraint(existing_name, type_='foreignkey')
|
||||||
|
batch.create_foreign_key(
|
||||||
|
new_name, 'recipe_tasks', ['task_id'], ['id'],
|
||||||
|
ondelete='CASCADE',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Reverse order: reference_images before results, mirroring upgrade().
|
||||||
|
for table_name, name in reversed(list(_NEW_FK_NAMES.items())):
|
||||||
|
with op.batch_alter_table(table_name) as batch:
|
||||||
|
batch.drop_constraint(name, type_='foreignkey')
|
||||||
|
batch.create_foreign_key(
|
||||||
|
name, 'recipe_tasks', ['task_id'], ['id'],
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""API keys as SHA-256 hashes, one per session (#41)
|
||||||
|
|
||||||
|
Replaces the plaintext `users.api_key` column with an `api_keys` table: one
|
||||||
|
row per login, holding only the SHA-256 hex digest, an expiry and the last
|
||||||
|
use. Existing keys are invalidated on purpose, not migrated - the plaintext
|
||||||
|
is exactly what must stop existing, so everybody logs in once again.
|
||||||
|
|
||||||
|
Revision ID: 015_api_key_hash
|
||||||
|
Revises: 014_vision_fk_ondelete
|
||||||
|
Create Date: 2026-10-06
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '015_api_key_hash'
|
||||||
|
down_revision: Union[str, None] = '014_vision_fk_ondelete'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Idempotent on purpose (#71): a database born from create_all has no
|
||||||
|
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
|
||||||
|
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
|
||||||
|
# created and `alembic_version` still at 014. Every step checks first.
|
||||||
|
insp = sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
if 'api_keys' not in insp.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
'api_keys',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
existing_idx: set[str] = set()
|
||||||
|
else:
|
||||||
|
existing_idx = {i['name'] for i in insp.get_indexes('api_keys')}
|
||||||
|
|
||||||
|
if 'ix_api_keys_key_hash' not in existing_idx:
|
||||||
|
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||||
|
if 'ix_api_keys_user_id' not in existing_idx:
|
||||||
|
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||||
|
|
||||||
|
if 'api_key' not in {c['name'] for c in insp.get_columns('users')}:
|
||||||
|
return # column already gone: nothing else to do
|
||||||
|
|
||||||
|
user_idx = {i['name'] for i in insp.get_indexes('users')}
|
||||||
|
user_uq = {u['name'] for u in insp.get_unique_constraints('users')}
|
||||||
|
with op.batch_alter_table('users') as batch:
|
||||||
|
if 'ix_users_api_key' in user_idx:
|
||||||
|
batch.drop_index('ix_users_api_key')
|
||||||
|
if 'uq_users_api_key' in user_uq:
|
||||||
|
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||||
|
batch.drop_column('api_key')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users') as batch:
|
||||||
|
batch.add_column(sa.Column('api_key', sa.String(64), nullable=True))
|
||||||
|
batch.create_unique_constraint('uq_users_api_key', ['api_key'])
|
||||||
|
batch.create_index('ix_users_api_key', ['api_key'])
|
||||||
|
|
||||||
|
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
|
||||||
|
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
|
||||||
|
op.drop_table('api_keys')
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46)
|
||||||
|
|
||||||
|
Percorso relativo sotto upload_path della cartella immutabile che contiene
|
||||||
|
recipe.json e i file di meta (DXF, modelli .npz, ...).
|
||||||
|
|
||||||
|
Revision ID: 016_vision_recipe_dir
|
||||||
|
Revises: 015_api_key_hash
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '016_vision_recipe_dir'
|
||||||
|
down_revision: Union[str, None] = '015_api_key_hash'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.drop_column('vision_recipe_dir')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""users: PIN a 4 cifre per login rapido e capoturno (#59)
|
||||||
|
|
||||||
|
pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco.
|
||||||
|
|
||||||
|
Revision ID: 017_user_pin
|
||||||
|
Revises: 016_vision_recipe_dir
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '017_user_pin'
|
||||||
|
down_revision: Union[str, None] = '016_vision_recipe_dir'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||||
|
with op.batch_alter_table('users') as batch:
|
||||||
|
batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True))
|
||||||
|
batch.add_column(sa.Column(
|
||||||
|
'pin_failed_count', sa.Integer(), nullable=False, server_default='0'))
|
||||||
|
batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users') as batch:
|
||||||
|
batch.drop_column('pin_locked_until')
|
||||||
|
batch.drop_column('pin_failed_count')
|
||||||
|
batch.drop_column('pin_hash')
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""task: in_loop / at_close / fields, tipo 'check', tabella task_results (#53 #54 #55)
|
||||||
|
|
||||||
|
in_loop: il task fa parte del ciclo di produzione (prima: implicito dal tipo).
|
||||||
|
at_close: il task 'note' si compila alla fine produzione.
|
||||||
|
fields: campi extra del task 'note' (JSON).
|
||||||
|
task_results: esito registrato da un task 'check' (OK/NOK) o 'note' (done).
|
||||||
|
|
||||||
|
Revision ID: 018_task_loop_results
|
||||||
|
Revises: 017_user_pin
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '018_task_loop_results'
|
||||||
|
down_revision: Union[str, None] = '017_user_pin'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
||||||
|
NEW_TYPES = OLD_TYPES + ('check',)
|
||||||
|
# stessi tipi di MEASURING_TASK_TYPES: fino a oggi erano l'unico ciclo possibile
|
||||||
|
LOOP_TYPES = ('measure', 'camera_measure', 'dxf_compare')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.add_column(sa.Column(
|
||||||
|
'in_loop', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
batch.add_column(sa.Column(
|
||||||
|
'at_close', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
batch.add_column(sa.Column('fields', sa.JSON(), nullable=True))
|
||||||
|
# enum allargato come in 011: 'check' = Controllo OK/NOK
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
|
||||||
|
# i task di misura erano gia' nel ciclo: lo restano
|
||||||
|
recipe_tasks = sa.table(
|
||||||
|
'recipe_tasks', sa.column('task_type', sa.String), sa.column('in_loop', sa.Boolean))
|
||||||
|
op.execute(
|
||||||
|
recipe_tasks.update()
|
||||||
|
.where(recipe_tasks.c.task_type.in_(LOOP_TYPES))
|
||||||
|
.values(in_loop=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'task_results',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('version_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_versions.id'), nullable=False),
|
||||||
|
sa.Column('production_run_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('production_runs.id', ondelete='SET NULL'), nullable=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('outcome', sa.Enum('ok', 'nok', 'done', name='task_outcome_enum'),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column('note', sa.Text(), nullable=True),
|
||||||
|
sa.Column('values', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('attachments', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
op.create_index('ix_task_results_task_id', 'task_results', ['task_id'])
|
||||||
|
op.create_index('ix_task_results_version_id', 'task_results', ['version_id'])
|
||||||
|
op.create_index(
|
||||||
|
'ix_task_results_production_run_id', 'task_results', ['production_run_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# il tipo 'check' non esiste prima di 018: se un task lo usa, tornare indietro
|
||||||
|
# lo lascerebbe con un valore fuori enum
|
||||||
|
bind = op.get_bind()
|
||||||
|
used = bind.execute(
|
||||||
|
sa.text("SELECT COUNT(*) FROM recipe_tasks WHERE task_type = 'check'")
|
||||||
|
).scalar()
|
||||||
|
if used:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Downgrade 018 impossibile: {used} task usano il tipo 'check'. "
|
||||||
|
"Cambiare il tipo o eliminare quei task prima di tornare indietro."
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_index('ix_task_results_production_run_id', table_name='task_results')
|
||||||
|
op.drop_index('ix_task_results_version_id', table_name='task_results')
|
||||||
|
op.drop_index('ix_task_results_task_id', table_name='task_results')
|
||||||
|
op.drop_table('task_results')
|
||||||
|
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
batch.drop_column('fields')
|
||||||
|
batch.drop_column('at_close')
|
||||||
|
batch.drop_column('in_loop')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""stations.vision_worker_url e vision_results.worker_url (#45)
|
||||||
|
|
||||||
|
stations.vision_worker_url: worker di visione della stazione (NULL = centrale).
|
||||||
|
vision_results.worker_url: worker effettivamente chiamato per quel risultato.
|
||||||
|
|
||||||
|
Revision ID: 019_station_vision_worker
|
||||||
|
Revises: 018_task_loop_results
|
||||||
|
Create Date: 2026-10-11
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '019_station_vision_worker'
|
||||||
|
down_revision: Union[str, None] = '018_task_loop_results'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||||
|
with op.batch_alter_table('stations') as batch:
|
||||||
|
batch.add_column(sa.Column('vision_worker_url', sa.String(500), nullable=True))
|
||||||
|
with op.batch_alter_table('vision_results') as batch:
|
||||||
|
batch.add_column(sa.Column('worker_url', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('vision_results') as batch:
|
||||||
|
batch.drop_column('worker_url')
|
||||||
|
with op.batch_alter_table('stations') as batch:
|
||||||
|
batch.drop_column('vision_worker_url')
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""stations.camera_url e stations.camera_source (#72)
|
||||||
|
|
||||||
|
stations.camera_url: servizio camera della stazione (vs-gui o equivalente).
|
||||||
|
stations.camera_source: sorgente da chiedere al servizio, es. camera:<seriale>
|
||||||
|
o camera:folder.
|
||||||
|
|
||||||
|
Revision ID: 020_station_camera
|
||||||
|
Revises: 019_station_vision_worker
|
||||||
|
Create Date: 2026-10-15
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '020_station_camera'
|
||||||
|
down_revision: Union[str, None] = '019_station_vision_worker'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||||
|
with op.batch_alter_table('stations') as batch:
|
||||||
|
batch.add_column(sa.Column('camera_url', sa.String(500), nullable=True))
|
||||||
|
batch.add_column(sa.Column('camera_source', sa.String(200), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('stations') as batch:
|
||||||
|
batch.drop_column('camera_source')
|
||||||
|
batch.drop_column('camera_url')
|
||||||
@@ -1,17 +1,29 @@
|
|||||||
"""Pydantic schemas for Measurement operations."""
|
"""Pydantic schemas for Measurement operations."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class MeasurementCreate(BaseModel):
|
class MeasurementCreate(BaseModel):
|
||||||
"""Schema for creating a single measurement."""
|
"""Schema for creating a single measurement."""
|
||||||
subtask_id: int
|
subtask_id: int
|
||||||
version_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
|
value: float
|
||||||
lot_number: Optional[str] = Field(None, max_length=100)
|
lot_number: Optional[str] = Field(None, max_length=100)
|
||||||
serial_number: Optional[str] = Field(None, max_length=100)
|
serial_number: Optional[str] = Field(None, max_length=100)
|
||||||
|
# "camera" is deliberately absent from this pattern, even though the ORM
|
||||||
|
# enum and the database allow it. Do not add it here: a camera
|
||||||
|
# measurement must be produced by vision_service.execute_task, never by a
|
||||||
|
# client posting JSON straight to this endpoint - there is no image, no
|
||||||
|
# graph and no engine_version behind a value that arrived this way.
|
||||||
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
||||||
input_duration_ms: Optional[int] = Field(None, ge=0)
|
input_duration_ms: Optional[int] = Field(None, ge=0)
|
||||||
# The production this belongs to, when one is open at the station.
|
# The production this belongs to, when one is open at the station.
|
||||||
@@ -42,8 +54,8 @@ class MeasurementResponse(BaseModel):
|
|||||||
# Who let this value stand, when it was out of tolerance. Null on a value in
|
# 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.
|
# tolerance - and on one that is not and is still waiting for an answer.
|
||||||
supervisor_id: Optional[int] = None
|
supervisor_id: Optional[int] = None
|
||||||
authorised_at: Optional[datetime] = None
|
authorised_at: Optional[UtcDateTime] = None
|
||||||
measured_at: datetime
|
measured_at: UtcDateTime
|
||||||
synced_to_csv: bool
|
synced_to_csv: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -59,8 +71,21 @@ class MeasurementListResponse(BaseModel):
|
|||||||
class MeasurementAuthorisation(BaseModel):
|
class MeasurementAuthorisation(BaseModel):
|
||||||
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
||||||
|
|
||||||
supervisor_username: str = Field(..., min_length=1)
|
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||||
supervisor_password: str = Field(..., min_length=1)
|
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):
|
class TaskProgressResponse(BaseModel):
|
||||||
@@ -79,6 +104,21 @@ class TaskProgressListResponse(BaseModel):
|
|||||||
tasks: list[TaskProgressResponse]
|
tasks: list[TaskProgressResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class CycleMeasurementResponse(BaseModel):
|
||||||
|
"""One quote already measured in the cycle under way."""
|
||||||
|
|
||||||
|
subtask_id: int
|
||||||
|
value: float
|
||||||
|
pass_fail: str
|
||||||
|
measured_at: UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentCycleResponse(BaseModel):
|
||||||
|
"""The quotes to restore on the screen after a reload; empty if the task is closed."""
|
||||||
|
|
||||||
|
measurements: list[CycleMeasurementResponse]
|
||||||
|
|
||||||
|
|
||||||
class MeasurementQuery(BaseModel):
|
class MeasurementQuery(BaseModel):
|
||||||
"""Schema for measurement query filters."""
|
"""Schema for measurement query filters."""
|
||||||
recipe_id: Optional[int] = None
|
recipe_id: Optional[int] = None
|
||||||
@@ -87,8 +127,8 @@ class MeasurementQuery(BaseModel):
|
|||||||
measured_by: Optional[int] = None
|
measured_by: Optional[int] = None
|
||||||
lot_number: Optional[str] = None
|
lot_number: Optional[str] = None
|
||||||
serial_number: Optional[str] = None
|
serial_number: Optional[str] = None
|
||||||
date_from: Optional[datetime] = None
|
date_from: UtcNaiveInput = None
|
||||||
date_to: Optional[datetime] = None
|
date_to: UtcNaiveInput = None
|
||||||
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
||||||
page: int = Field(1, ge=1)
|
page: int = Field(1, ge=1)
|
||||||
per_page: int = Field(50, ge=1, le=500)
|
per_page: int = Field(50, ge=1, le=500)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""API models for the live station monitor (#57)."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorSubtask(BaseModel):
|
||||||
|
"""Mean of the recent measurements of one quote, against its tolerance."""
|
||||||
|
|
||||||
|
subtask_id: int
|
||||||
|
description: str
|
||||||
|
unit: str
|
||||||
|
count: int
|
||||||
|
mean: float
|
||||||
|
ltl: Optional[float] = None
|
||||||
|
utl: Optional[float] = None
|
||||||
|
# True when ltl <= mean <= utl (a missing limit does not constrain).
|
||||||
|
in_tolerance: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorEvent(BaseModel):
|
||||||
|
event_type: str
|
||||||
|
created_at: datetime
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorRun(BaseModel):
|
||||||
|
id: int
|
||||||
|
station_code: str
|
||||||
|
station_name: str
|
||||||
|
recipe_code: str
|
||||||
|
recipe_name: str
|
||||||
|
version_number: int
|
||||||
|
operator: str
|
||||||
|
status: str
|
||||||
|
started_at: datetime
|
||||||
|
duration_seconds: int
|
||||||
|
cycle_count: int
|
||||||
|
seconds_to_next_measurement: Optional[int] = None
|
||||||
|
overdue: bool
|
||||||
|
last_event: Optional[MonitorEvent] = None
|
||||||
|
# Counts over the last MONITOR_WINDOW measurements of the run.
|
||||||
|
measured: int
|
||||||
|
pass_count: int
|
||||||
|
warning_count: int
|
||||||
|
fail_count: int
|
||||||
|
subtasks: list[MonitorSubtask]
|
||||||
|
server_time: datetime
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Pydantic schemas for production runs and their events."""
|
"""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 typing import Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class ProductionRunCreate(BaseModel):
|
class ProductionRunCreate(BaseModel):
|
||||||
@@ -21,7 +21,7 @@ class ProductionEventResponse(BaseModel):
|
|||||||
supervisor_id: Optional[int]
|
supervisor_id: Optional[int]
|
||||||
task_id: Optional[int] = None
|
task_id: Optional[int] = None
|
||||||
note: Optional[str]
|
note: Optional[str]
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
class ProductionRunResponse(BaseModel):
|
class ProductionRunResponse(BaseModel):
|
||||||
@@ -36,11 +36,11 @@ class ProductionRunResponse(BaseModel):
|
|||||||
serial_number: Optional[str]
|
serial_number: Optional[str]
|
||||||
status: str
|
status: str
|
||||||
measurement_interval_minutes: Optional[int]
|
measurement_interval_minutes: Optional[int]
|
||||||
next_measurement_at: Optional[datetime]
|
next_measurement_at: Optional[UtcDateTime]
|
||||||
cycle_count: int
|
cycle_count: int
|
||||||
started_at: datetime
|
started_at: UtcDateTime
|
||||||
paused_at: Optional[datetime]
|
paused_at: Optional[UtcDateTime]
|
||||||
closed_at: Optional[datetime]
|
closed_at: Optional[UtcDateTime]
|
||||||
closed_by: Optional[int]
|
closed_by: Optional[int]
|
||||||
statistics_path: Optional[str] = None
|
statistics_path: Optional[str] = None
|
||||||
|
|
||||||
@@ -49,12 +49,14 @@ class ProductionRunResponse(BaseModel):
|
|||||||
# overdue is a fact the operator must see, not deduce.
|
# overdue is a fact the operator must see, not deduce.
|
||||||
seconds_to_next_measurement: Optional[int] = None
|
seconds_to_next_measurement: Optional[int] = None
|
||||||
overdue: bool = False
|
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
|
# 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
|
# 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.
|
# expires, whichever screen they are on, and to know which task closes a cycle.
|
||||||
measurement_task_ids: list[int] = Field(default_factory=list)
|
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):
|
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||||
@@ -64,8 +66,21 @@ class ProductionRunWithEventsResponse(ProductionRunResponse):
|
|||||||
class SupervisorAction(BaseModel):
|
class SupervisorAction(BaseModel):
|
||||||
"""Credentials of the supervisor authorising a line stop or a close."""
|
"""Credentials of the supervisor authorising a line stop or a close."""
|
||||||
|
|
||||||
supervisor_username: str = Field(..., min_length=1)
|
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||||
supervisor_password: str = Field(..., min_length=1)
|
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
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for Recipe and RecipeVersion operations."""
|
"""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 typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -26,6 +26,12 @@ class RecipeCreate(BaseModel):
|
|||||||
annotations_json: Optional[dict] = None
|
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):
|
class RecipeUpdate(BaseModel):
|
||||||
"""Schema for updating a recipe (creates new version)."""
|
"""Schema for updating a recipe (creates new version)."""
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
@@ -51,7 +57,7 @@ class RecipeVersionResponse(BaseModel):
|
|||||||
version_number: int
|
version_number: int
|
||||||
is_current: bool
|
is_current: bool
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
change_notes: Optional[str] = None
|
change_notes: Optional[str] = None
|
||||||
tasks: list["TaskResponse"] = []
|
tasks: list["TaskResponse"] = []
|
||||||
|
|
||||||
@@ -72,7 +78,7 @@ class RecipeResponse(BaseModel):
|
|||||||
requires_serial: bool = False
|
requires_serial: bool = False
|
||||||
allow_manual_input: bool = False
|
allow_manual_input: bool = False
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
active: bool
|
active: bool
|
||||||
current_version: Optional[RecipeVersionResponse] = None
|
current_version: Optional[RecipeVersionResponse] = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,37 @@
|
|||||||
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Optional
|
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):
|
class StationCreate(BaseModel):
|
||||||
@@ -11,6 +40,15 @@ class StationCreate(BaseModel):
|
|||||||
location: Optional[str] = Field(default=None, max_length=255)
|
location: Optional[str] = Field(default=None, max_length=255)
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
active: bool = True
|
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):
|
class StationUpdate(BaseModel):
|
||||||
@@ -18,6 +56,15 @@ class StationUpdate(BaseModel):
|
|||||||
location: Optional[str] = Field(default=None, max_length=255)
|
location: Optional[str] = Field(default=None, max_length=255)
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
active: Optional[bool] = 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):
|
class StationResponse(BaseModel):
|
||||||
@@ -28,8 +75,11 @@ class StationResponse(BaseModel):
|
|||||||
location: Optional[str]
|
location: Optional[str]
|
||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
active: bool
|
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_by: int
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
class StationRecipeAssignmentCreate(BaseModel):
|
class StationRecipeAssignmentCreate(BaseModel):
|
||||||
@@ -42,7 +92,7 @@ class StationRecipeAssignmentResponse(BaseModel):
|
|||||||
station_id: int
|
station_id: int
|
||||||
recipe_id: int
|
recipe_id: int
|
||||||
assigned_by: int
|
assigned_by: int
|
||||||
assigned_at: datetime
|
assigned_at: UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
class RecipeSummary(BaseModel):
|
class RecipeSummary(BaseModel):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for SPC statistics responses."""
|
"""Pydantic schemas for SPC statistics responses."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
|
|||||||
recipe_id: int
|
recipe_id: int
|
||||||
version_id: Optional[int] = None
|
version_id: Optional[int] = None
|
||||||
subtask_id: Optional[int] = None
|
subtask_id: Optional[int] = None
|
||||||
date_from: Optional[datetime] = None
|
date_from: UtcNaiveInput = None
|
||||||
date_to: Optional[datetime] = None
|
date_to: UtcNaiveInput = None
|
||||||
operator_id: Optional[int] = None
|
operator_id: Optional[int] = None
|
||||||
lot_number: Optional[str] = None
|
lot_number: Optional[str] = None
|
||||||
serial_number: Optional[str] = None
|
serial_number: Optional[str] = None
|
||||||
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
|
|||||||
class ControlChartData(BaseModel):
|
class ControlChartData(BaseModel):
|
||||||
"""Data for X-bar/R control chart."""
|
"""Data for X-bar/R control chart."""
|
||||||
values: list[float]
|
values: list[float]
|
||||||
timestamps: list[datetime]
|
timestamps: list[UtcDateTime]
|
||||||
mean: float
|
mean: float
|
||||||
ucl: float # Upper Control Limit
|
ucl: float # Upper Control Limit
|
||||||
lcl: float # Lower Control Limit
|
lcl: float # Lower Control Limit
|
||||||
@@ -50,7 +50,8 @@ class CapabilityData(BaseModel):
|
|||||||
pp: Optional[float] = None
|
pp: Optional[float] = None
|
||||||
ppk: Optional[float] = None
|
ppk: Optional[float] = None
|
||||||
mean: float
|
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
|
n: int
|
||||||
utl: Optional[float] = None
|
utl: Optional[float] = None
|
||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
@@ -59,7 +60,7 @@ class CapabilityData(BaseModel):
|
|||||||
|
|
||||||
class TrendData(BaseModel):
|
class TrendData(BaseModel):
|
||||||
"""Temporal trend data for capability indices."""
|
"""Temporal trend data for capability indices."""
|
||||||
dates: list[datetime]
|
dates: list[UtcDateTime]
|
||||||
cpk_values: list[Optional[float]]
|
cpk_values: list[Optional[float]]
|
||||||
ppk_values: list[Optional[float]]
|
ppk_values: list[Optional[float]]
|
||||||
n_per_period: list[int]
|
n_per_period: list[int]
|
||||||
|
|||||||
@@ -1,14 +1,110 @@
|
|||||||
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
||||||
|
import re
|
||||||
from typing import Any, Literal, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from src.backend.models.orm.task import TASK_TYPES
|
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
|
||||||
|
|
||||||
# Kept in step with the ORM enum so the two cannot drift apart.
|
# Kept in step with the ORM enum so the two cannot drift apart.
|
||||||
TaskType = Literal[TASK_TYPES]
|
TaskType = Literal[TASK_TYPES]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tolerance_order(
|
||||||
|
ltl: Optional[float],
|
||||||
|
lwl: Optional[float],
|
||||||
|
nominal: Optional[float],
|
||||||
|
uwl: Optional[float],
|
||||||
|
utl: Optional[float],
|
||||||
|
) -> None:
|
||||||
|
"""ltl <= lwl <= nominal <= uwl <= utl, checked only across the values set.
|
||||||
|
|
||||||
|
A one-sided tolerance - only utl, say - is not an error: what is wrong is
|
||||||
|
limits that invert the sequence they do share, like utl landing below ltl.
|
||||||
|
Shared by SubtaskCreate's own validator and by the router's partial-update
|
||||||
|
check (SubtaskUpdate has no fixed set of fields to compare).
|
||||||
|
"""
|
||||||
|
ordered = [
|
||||||
|
("ltl", ltl), ("lwl", lwl), ("nominal", nominal), ("uwl", uwl), ("utl", utl),
|
||||||
|
]
|
||||||
|
present = [(name, value) for name, value in ordered if value is not None]
|
||||||
|
for (prev_name, prev), (name, value) in zip(present, present[1:]):
|
||||||
|
if prev > value:
|
||||||
|
raise ValueError(
|
||||||
|
f"Tolerance limits out of order: {prev_name}={prev} > {name}={value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# #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):
|
class SubtaskCreate(BaseModel):
|
||||||
"""Schema for creating a subtask."""
|
"""Schema for creating a subtask."""
|
||||||
marker_number: int = Field(..., ge=1)
|
marker_number: int = Field(..., ge=1)
|
||||||
@@ -21,6 +117,12 @@ class SubtaskCreate(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: str = Field("mm", max_length=20)
|
unit: str = Field("mm", max_length=20)
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
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):
|
class SubtaskUpdate(BaseModel):
|
||||||
@@ -35,6 +137,7 @@ class SubtaskUpdate(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: Optional[str] = Field(None, max_length=20)
|
unit: Optional[str] = Field(None, max_length=20)
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SubtaskResponse(BaseModel):
|
class SubtaskResponse(BaseModel):
|
||||||
@@ -53,6 +156,7 @@ class SubtaskResponse(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: str
|
unit: str
|
||||||
image_path: Optional[str] = None
|
image_path: Optional[str] = None
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(BaseModel):
|
class TaskCreate(BaseModel):
|
||||||
@@ -64,6 +168,11 @@ class TaskCreate(BaseModel):
|
|||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
|
# #53: assente = default dal tipo (misure e check nel ciclo)
|
||||||
|
in_loop: Optional[bool] = None
|
||||||
|
at_close: bool = False
|
||||||
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
subtasks: list[SubtaskCreate] = []
|
subtasks: list[SubtaskCreate] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +185,10 @@ class TaskUpdate(BaseModel):
|
|||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
|
in_loop: Optional[bool] = None
|
||||||
|
at_close: Optional[bool] = None
|
||||||
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskResponse(BaseModel):
|
class TaskResponse(BaseModel):
|
||||||
@@ -93,6 +206,10 @@ class TaskResponse(BaseModel):
|
|||||||
file_path: Optional[str] = None
|
file_path: Optional[str] = None
|
||||||
file_type: Optional[str] = None
|
file_type: Optional[str] = None
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
|
in_loop: bool = False
|
||||||
|
at_close: bool = False
|
||||||
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
subtasks: list[SubtaskResponse] = []
|
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."""
|
"""Pydantic schemas for User operations."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -48,8 +48,10 @@ class UserResponse(BaseModel):
|
|||||||
language_pref: str
|
language_pref: str
|
||||||
theme_pref: str
|
theme_pref: str
|
||||||
active: bool
|
active: bool
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
last_login: Optional[datetime] = None
|
last_login: Optional[UtcDateTime] = None
|
||||||
|
# #59: solo il fatto che il PIN esista, mai l'hash
|
||||||
|
has_pin: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UserPasswordChange(BaseModel):
|
class UserPasswordChange(BaseModel):
|
||||||
@@ -68,3 +70,21 @@ class LoginResponse(BaseModel):
|
|||||||
user: UserResponse
|
user: UserResponse
|
||||||
api_key: str
|
api_key: str
|
||||||
message: str = "Login successful"
|
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}$")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Pydantic schemas for the vision execution response."""
|
||||||
|
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
|
||||||
|
pass_fail: str
|
||||||
|
|
||||||
|
|
||||||
|
class VisionExecuteResponse(BaseModel):
|
||||||
|
"""What `POST /api/vision/execute` returns: the run, and every quote it filled."""
|
||||||
|
vision_result_id: int
|
||||||
|
engine_version: str
|
||||||
|
measurements: list[VisionMeasurementResult]
|
||||||
|
# Esito VisionSuite (informativo: il verdetto lo danno utl/ltl), overlay
|
||||||
|
# in px immagine, URL dell'immagine salvata e strumenti falliti (#46).
|
||||||
|
outcome: str | None = None
|
||||||
|
overlay: list[dict] = []
|
||||||
|
image_url: str | None = None
|
||||||
|
failures: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class VisionRecipeOutput(BaseModel):
|
||||||
|
"""One numeric output a recipe offers to the quotes."""
|
||||||
|
key: str
|
||||||
|
quantity: str | None = None
|
||||||
|
unit: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VisionRecipeUploadResponse(BaseModel):
|
||||||
|
task_id: int
|
||||||
|
recipe_dir: str
|
||||||
|
files: list[str]
|
||||||
|
outputs: list[VisionRecipeOutput]
|
||||||
|
|
||||||
|
|
||||||
|
class VisionRecipeResponse(BaseModel):
|
||||||
|
task_id: int
|
||||||
|
recipe_dir: str | None = None
|
||||||
|
recipe: dict | None = None
|
||||||
|
files: list[str]
|
||||||
|
outputs: list[VisionRecipeOutput]
|
||||||
|
|
||||||
|
|
||||||
|
class VisionReferenceImageResponse(BaseModel):
|
||||||
|
"""One saved reference image, as `GET /api/vision/reference-images` lists it."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
task_id: int
|
||||||
|
path: str
|
||||||
|
note: str | None = None
|
||||||
|
station_id: int | None = None
|
||||||
|
device_code: str | None = None
|
||||||
|
acquired_at: UtcDateTime
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
"""SQLAlchemy models for TieMeasureFlow."""
|
"""SQLAlchemy models for TieMeasureFlow."""
|
||||||
from src.backend.models.orm.user import User
|
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.recipe import Recipe, RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
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.measurement import Measurement
|
||||||
from src.backend.models.orm.access_log import AccessLog
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from src.backend.models.orm.production import ProductionRun, ProductionEvent
|
from src.backend.models.orm.production import ProductionRun, ProductionEvent
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
|
"ApiKey",
|
||||||
"Recipe",
|
"Recipe",
|
||||||
"RecipeVersion",
|
"RecipeVersion",
|
||||||
"RecipeTask",
|
"RecipeTask",
|
||||||
"RecipeSubtask",
|
"RecipeSubtask",
|
||||||
|
"TaskResult",
|
||||||
"Measurement",
|
"Measurement",
|
||||||
"AccessLog",
|
"AccessLog",
|
||||||
"SystemSetting",
|
"SystemSetting",
|
||||||
@@ -22,4 +27,6 @@ __all__ = [
|
|||||||
"StationRecipeAssignment",
|
"StationRecipeAssignment",
|
||||||
"ProductionRun",
|
"ProductionRun",
|
||||||
"ProductionEvent",
|
"ProductionEvent",
|
||||||
|
"VisionResult",
|
||||||
|
"VisionReferenceImage",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -38,11 +38,17 @@ class Measurement(Base):
|
|||||||
|
|
||||||
# Input method
|
# Input method
|
||||||
input_method: Mapped[str] = mapped_column(
|
input_method: Mapped[str] = mapped_column(
|
||||||
Enum("usb_caliper", "manual", name="input_method_enum"),
|
Enum("usb_caliper", "manual", "camera", name="input_method_enum"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default="manual",
|
default="manual",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The vision execution that produced this value, when there was one. Null for
|
||||||
|
# everything taken by caliper or typed by hand.
|
||||||
|
vision_result_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("vision_results.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Time spent by the operator entering this value (client-reported)
|
# Time spent by the operator entering this value (client-reported)
|
||||||
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer, nullable=True
|
Integer, nullable=True
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class Station(Base):
|
|||||||
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=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_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, nullable=False, server_default=func.now()
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import (
|
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
|
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
|
# 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
|
# 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.
|
# because widening a MySQL enum later is an ALTER on a live table.
|
||||||
TASK_TYPES = ("note", "measure", "drawing", "xf_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".
|
# 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):
|
class RecipeTask(Base):
|
||||||
@@ -47,6 +58,24 @@ class RecipeTask(Base):
|
|||||||
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
||||||
)
|
)
|
||||||
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
# The vision graph, serialised by vs-task. Opaque here on purpose: the
|
||||||
|
# server routes it and never interprets it, which is what keeps VisionSuite
|
||||||
|
# out of this container.
|
||||||
|
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
||||||
|
# meta della ricetta VisionSuite v2 (#46).
|
||||||
|
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
|
||||||
|
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
|
||||||
|
in_loop: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=_default_in_loop, server_default="0"
|
||||||
|
)
|
||||||
|
# #53: task 'note' compilato alla fine produzione, prima della modale del capoturno.
|
||||||
|
at_close: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="0"
|
||||||
|
)
|
||||||
|
# #55: campi extra del task 'note' ([{key,label,type,default,attachment}]).
|
||||||
|
fields: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||||
@@ -89,6 +118,12 @@ class RecipeSubtask(Base):
|
|||||||
|
|
||||||
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
||||||
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# Which output of the vision graph fills this quote: "<tool_id>.<output>".
|
||||||
|
# A link is an id, not a pointer - the same rule vs-task follows, and for
|
||||||
|
# the same reason: a pointer does not survive being written to disk.
|
||||||
|
vision_output: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(120), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
||||||
|
|||||||
@@ -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
|
# Admin flag separate from roles
|
||||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
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
|
# User preferences
|
||||||
language_pref: Mapped[str] = mapped_column(
|
language_pref: Mapped[str] = mapped_column(
|
||||||
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
||||||
@@ -32,7 +39,6 @@ class User(Base):
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
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
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
@@ -40,6 +46,11 @@ class User(Base):
|
|||||||
)
|
)
|
||||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
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:
|
def has_role(self, role: str) -> bool:
|
||||||
"""Check if user has a specific role."""
|
"""Check if user has a specific role."""
|
||||||
return role in (self.roles or [])
|
return role in (self.roles or [])
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""One row per vision execution - not per quote."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class VisionResult(Base):
|
||||||
|
__tablename__ = "vision_results"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id", 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)
|
||||||
|
engine_version: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
executed_on: Mapped[str] = mapped_column(
|
||||||
|
Enum("server", "station", name="vision_executed_on_enum"), nullable=False
|
||||||
|
)
|
||||||
|
station_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("stations.id"), nullable=True
|
||||||
|
)
|
||||||
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
# 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)
|
||||||
|
graph_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
executed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<VisionResult task={self.task_id} engine={self.engine_version}>"
|
||||||
|
|
||||||
|
|
||||||
|
class VisionReferenceImage(Base):
|
||||||
|
"""An image kept so a graph can be composed, tried and re-tried without a
|
||||||
|
camera - which is exactly what VisionSuite's source abstraction is for."""
|
||||||
|
|
||||||
|
__tablename__ = "vision_reference_images"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
|
)
|
||||||
|
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
station_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("stations.id"), nullable=True
|
||||||
|
)
|
||||||
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
engine_version: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
note: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
expected_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
acquired_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<VisionReferenceImage task={self.task_id} path={self.path}>"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""AI service for parsing technical sheets via OpenRouter."""
|
"""AI service for parsing technical sheets via OpenRouter."""
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@@ -10,6 +11,17 @@ from src.backend.config import settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
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.
|
Analizza il testo estratto da una scheda tecnica e identifica i blocchi operativi distinti.
|
||||||
Per ogni blocco, estrai:
|
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]:
|
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():
|
if not text.strip():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -62,7 +76,10 @@ async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
data = resp.json()
|
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
|
# Strip markdown fences if present
|
||||||
if content.startswith("```"):
|
if content.startswith("```"):
|
||||||
@@ -71,4 +88,10 @@ async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
|||||||
content = content[:-3]
|
content = content[:-3]
|
||||||
content = content.strip()
|
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."""
|
"""Authentication service - password hashing, API key management."""
|
||||||
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import delete, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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
|
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
|
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(
|
async def authenticate_user(
|
||||||
db: AsyncSession, username: str, password: str
|
db: AsyncSession, username: str, password: str
|
||||||
) -> User | None:
|
) -> User | None:
|
||||||
@@ -35,12 +46,87 @@ async def authenticate_user(
|
|||||||
select(User).where(User.username == username, User.active == True)
|
select(User).where(User.username == username, User.active == True)
|
||||||
)
|
)
|
||||||
user = result.scalar_one_or_none()
|
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 None
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
|
# #59: PIN a 4 cifre. Hash identico alle password (bcrypt); 5 errori -> blocco 15 minuti.
|
||||||
|
PIN_MAX_FAILURES = 5
|
||||||
|
PIN_LOCK_MINUTES = 15
|
||||||
|
|
||||||
|
|
||||||
|
async def set_user_pin(db: AsyncSession, user: User, pin: str | None) -> None:
|
||||||
|
"""Imposta o toglie (None) il PIN; azzera contatore e blocco. Admin esclusi (#59)."""
|
||||||
|
if pin is not None and user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Admin users cannot have a PIN",
|
||||||
|
)
|
||||||
|
user.pin_hash = hash_password(pin) if pin is not None else None
|
||||||
|
user.pin_failed_count = 0
|
||||||
|
user.pin_locked_until = None
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_pin(db: AsyncSession, user_id: int, pin: str) -> User:
|
||||||
|
"""Verifica user_id+PIN con blocco dopo 5 errori consecutivi (#59).
|
||||||
|
|
||||||
|
401 PIN errato (o utente non idoneo: stessa risposta, niente enumerazione),
|
||||||
|
423 utente bloccato. Il contatore va committato prima di sollevare: get_db fa
|
||||||
|
rollback su eccezione e l'errore andrebbe perso.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.id == user_id, User.active == True) # noqa: E712
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None or user.is_admin or user.pin_hash is None:
|
||||||
|
verify_password(pin, _DUMMY_PASSWORD_HASH) # stesso costo di un PIN errato
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN"
|
||||||
|
)
|
||||||
|
now = utcnow()
|
||||||
|
if user.pin_locked_until is not None:
|
||||||
|
if user.pin_locked_until > now:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_423_LOCKED,
|
||||||
|
detail="PIN locked until "
|
||||||
|
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
+ " UTC",
|
||||||
|
)
|
||||||
|
# blocco scaduto: si riparte da zero
|
||||||
|
user.pin_locked_until = None
|
||||||
|
user.pin_failed_count = 0
|
||||||
|
if verify_password(pin, user.pin_hash):
|
||||||
|
if user.pin_failed_count:
|
||||||
|
user.pin_failed_count = 0
|
||||||
|
await db.flush()
|
||||||
|
return user
|
||||||
|
user.pin_failed_count += 1
|
||||||
|
locked = user.pin_failed_count >= PIN_MAX_FAILURES
|
||||||
|
if locked:
|
||||||
|
user.pin_locked_until = now + timedelta(minutes=PIN_LOCK_MINUTES)
|
||||||
|
await db.commit()
|
||||||
|
if locked:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_423_LOCKED,
|
||||||
|
detail="PIN locked until "
|
||||||
|
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
+ " UTC",
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN")
|
||||||
|
|
||||||
|
|
||||||
|
async def authorise_supervisor(
|
||||||
|
db: AsyncSession,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
pin: str | None = None,
|
||||||
|
) -> User:
|
||||||
"""Check the credentials of the supervisor authorising something.
|
"""Check the credentials of the supervisor authorising something.
|
||||||
|
|
||||||
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
|
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
|
of them because it is the same question every time - who is this, and are they
|
||||||
allowed to say yes.
|
allowed to say yes.
|
||||||
|
|
||||||
Uses authenticate_user rather than a full login: logging in would rotate the
|
Uses authenticate_user rather than a full login: logging in would issue a
|
||||||
supervisor's API key and knock out whatever session they have open elsewhere.
|
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:
|
if supervisor is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -65,24 +160,67 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
|||||||
return supervisor
|
return supervisor
|
||||||
|
|
||||||
|
|
||||||
async def login_user(db: AsyncSession, user: User) -> str:
|
def hash_api_key(key: str) -> str:
|
||||||
"""Generate API key and update last_login for user."""
|
"""SHA-256 hex digest of an API key; this is all the DB ever stores."""
|
||||||
api_key = generate_api_key()
|
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(
|
await db.execute(
|
||||||
update(User)
|
delete(ApiKey).where(ApiKey.user_id == user.id, ApiKey.expires_at <= now)
|
||||||
.where(User.id == user.id)
|
)
|
||||||
.values(api_key=api_key, last_login=datetime.utcnow())
|
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()
|
await db.flush()
|
||||||
return api_key
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
async def logout_user(db: AsyncSession, user: User) -> None:
|
async def logout_user(
|
||||||
"""Invalidate user's API key."""
|
db: AsyncSession, user: User, key: str | None = None, all_sessions: bool = False
|
||||||
await db.execute(
|
) -> None:
|
||||||
update(User).where(User.id == user.id).values(api_key=None)
|
"""Log out. Deletes only the key of this session; all_sessions=True
|
||||||
)
|
deletes every key of the user."""
|
||||||
await db.flush()
|
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(
|
async def create_user(
|
||||||
@@ -113,11 +251,7 @@ async def create_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
|
async def regenerate_api_key(db: AsyncSession, user: User) -> str:
|
||||||
"""Regenerate API key for a user."""
|
"""Revoke every key of the user and issue a fresh one."""
|
||||||
new_key = generate_api_key()
|
await revoke_all_keys(db, user.id)
|
||||||
await db.execute(
|
return await issue_api_key(db, user)
|
||||||
update(User).where(User.id == user_id).values(api_key=new_key)
|
|
||||||
)
|
|
||||||
await db.flush()
|
|
||||||
return new_key
|
|
||||||
|
|||||||
@@ -1,18 +1,54 @@
|
|||||||
"""Measurement service - pass/fail calculation, data storage."""
|
"""Measurement service - pass/fail calculation, data storage."""
|
||||||
|
import math
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.models.orm.measurement import Measurement
|
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.recipe import Recipe, RecipeVersion
|
||||||
from src.backend.models.orm.task import (
|
from src.backend.models.orm.task import (
|
||||||
MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask,
|
MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask,
|
||||||
)
|
)
|
||||||
from src.backend.models.orm.user import User
|
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(
|
def calculate_pass_fail(
|
||||||
value: float, subtask: RecipeSubtask
|
value: float, subtask: RecipeSubtask
|
||||||
@@ -25,7 +61,12 @@ def calculate_pass_fail(
|
|||||||
- If value is outside UTL/LTL -> 'fail'
|
- If value is outside UTL/LTL -> 'fail'
|
||||||
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
|
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
|
||||||
- Otherwise -> 'pass'
|
- 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
|
deviation = None
|
||||||
if subtask.nominal is not None:
|
if subtask.nominal is not None:
|
||||||
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
|
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
|
||||||
@@ -202,12 +243,108 @@ async def task_progress(
|
|||||||
return progress
|
return progress
|
||||||
|
|
||||||
|
|
||||||
|
async def current_cycle_measurements(
|
||||||
|
db: AsyncSession, run_id: int, task_id: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""The quotes of a task already measured in the cycle that is under way.
|
||||||
|
|
||||||
|
A reload, a timer redirect or an auto-logout throws away what the page held in
|
||||||
|
memory; the operator would measure the same quotes again and the duplicates
|
||||||
|
would reach the SPC statistics. The cycle needs no column of its own: it starts
|
||||||
|
at the latest event of the run that opens one for this task - the run start, a
|
||||||
|
cycle_completed, or a remeasure of this task.
|
||||||
|
|
||||||
|
If a task_measured of this task follows that boundary the task is already
|
||||||
|
closed in this cycle and there is nothing to restore. Where a quote was measured
|
||||||
|
more than once, the last reading stands.
|
||||||
|
"""
|
||||||
|
boundary = (await db.execute(
|
||||||
|
select(ProductionEvent.id, ProductionEvent.created_at)
|
||||||
|
.where(
|
||||||
|
ProductionEvent.run_id == run_id,
|
||||||
|
(ProductionEvent.event_type.in_(("start", "cycle_completed")))
|
||||||
|
| (
|
||||||
|
(ProductionEvent.event_type == "remeasure")
|
||||||
|
& (ProductionEvent.task_id == task_id)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(ProductionEvent.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)).first()
|
||||||
|
|
||||||
|
event_filter = [ProductionEvent.run_id == run_id]
|
||||||
|
measurement_filter = [Measurement.production_run_id == run_id]
|
||||||
|
if boundary is not None:
|
||||||
|
boundary_id, boundary_at = boundary
|
||||||
|
event_filter.append(ProductionEvent.id > boundary_id)
|
||||||
|
measurement_filter.append(Measurement.measured_at > boundary_at)
|
||||||
|
|
||||||
|
closed = (await db.execute(
|
||||||
|
select(ProductionEvent.id).where(
|
||||||
|
*event_filter,
|
||||||
|
ProductionEvent.event_type == "task_measured",
|
||||||
|
ProductionEvent.task_id == task_id,
|
||||||
|
).limit(1)
|
||||||
|
)).first()
|
||||||
|
if closed is not None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(Measurement)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id)
|
||||||
|
.where(*measurement_filter, RecipeSubtask.task_id == task_id)
|
||||||
|
.order_by(Measurement.measured_at, Measurement.id)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
latest: dict[int, Measurement] = {}
|
||||||
|
for m in rows:
|
||||||
|
latest[m.subtask_id] = m
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"subtask_id": m.subtask_id,
|
||||||
|
"value": float(m.value),
|
||||||
|
"pass_fail": m.pass_fail,
|
||||||
|
"measured_at": m.measured_at,
|
||||||
|
}
|
||||||
|
for m in sorted(latest.values(), key=lambda m: (m.measured_at, m.id))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_run_for_version(
|
||||||
|
db: AsyncSession, run_id: int, version_id: int,
|
||||||
|
) -> "ProductionRun":
|
||||||
|
"""The run a measurement is being filed against, checked for coherence.
|
||||||
|
|
||||||
|
Three things have to hold before a value goes in against a production: the
|
||||||
|
run has to exist, it has to belong to the same recipe as the version being
|
||||||
|
measured (a run started for another product must not collect another
|
||||||
|
product's history), and it has to be in a state that accepts measurements
|
||||||
|
(not closed, not paused). Shared with vision_service, which files camera
|
||||||
|
measurements against a run the same way a manual reading does.
|
||||||
|
"""
|
||||||
|
# Local import: production_service imports this module at module scope,
|
||||||
|
# so importing it back here at module scope would be circular.
|
||||||
|
from src.backend.services import production_service
|
||||||
|
|
||||||
|
run = await production_service.get_run(db, run_id) # 404 if missing
|
||||||
|
|
||||||
|
recipe = await recipe_of_version(db, version_id)
|
||||||
|
if recipe is None or run.recipe_id != recipe.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="Production run belongs to a different recipe than this version",
|
||||||
|
)
|
||||||
|
|
||||||
|
production_service._require_measurable(run) # 409 if closed or paused
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
async def authorise_measurement(
|
async def authorise_measurement(
|
||||||
db: AsyncSession, measurement: Measurement, supervisor: User,
|
db: AsyncSession, measurement: Measurement, supervisor: User,
|
||||||
) -> Measurement:
|
) -> Measurement:
|
||||||
"""Let an out-of-tolerance value stand, on the supervisor's name."""
|
"""Let an out-of-tolerance value stand, on the supervisor's name."""
|
||||||
measurement.supervisor_id = supervisor.id
|
measurement.supervisor_id = supervisor.id
|
||||||
measurement.authorised_at = datetime.now()
|
measurement.authorised_at = utcnow()
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(measurement)
|
await db.refresh(measurement)
|
||||||
return measurement
|
return measurement
|
||||||
@@ -224,8 +361,20 @@ async def save_measurement(
|
|||||||
input_method: str = "manual",
|
input_method: str = "manual",
|
||||||
input_duration_ms: int | None = None,
|
input_duration_ms: int | None = None,
|
||||||
production_run_id: int | None = None,
|
production_run_id: int | None = None,
|
||||||
|
vision_result_id: int | None = None,
|
||||||
|
enforce_tolerance_gate: bool = True,
|
||||||
) -> Measurement:
|
) -> Measurement:
|
||||||
"""Save a single measurement with auto-calculated pass/fail."""
|
"""Save a single measurement with auto-calculated pass/fail.
|
||||||
|
|
||||||
|
``enforce_tolerance_gate=False`` skips only the pending-authorisation check
|
||||||
|
below - everything else (verdict, recipe rules, deviation) still applies.
|
||||||
|
This is for a caller that has already evaluated the gate itself for a whole
|
||||||
|
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
|
||||||
|
the gate models "moving on" between measurements taken one after another,
|
||||||
|
which does not exist inside a single camera acquisition.
|
||||||
|
"""
|
||||||
|
_validate_measured_value(value)
|
||||||
|
|
||||||
# Get subtask for tolerance values
|
# Get subtask for tolerance values
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||||
@@ -234,24 +383,45 @@ async def save_measurement(
|
|||||||
if subtask is None:
|
if subtask is None:
|
||||||
raise ValueError(f"Subtask {subtask_id} not found")
|
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)
|
recipe = await recipe_of_version(db, version_id)
|
||||||
_enforce_recipe_rules(recipe, lot_number, serial_number, input_method)
|
_enforce_recipe_rules(recipe, lot_number, serial_number, input_method)
|
||||||
|
|
||||||
# No moving on while a quote is out of tolerance and unauthorised. Measuring
|
# No moving on while a quote is out of tolerance and unauthorised. Measuring
|
||||||
# that same quote again is the way out that does not need the capoturno; the
|
# that same quote again is the way out that does not need the capoturno; the
|
||||||
# next quote is not.
|
# next quote is not.
|
||||||
blocking = await pending_authorisation(
|
if enforce_tolerance_gate:
|
||||||
db, version_id, measured_by, production_run_id,
|
blocking = await pending_authorisation(
|
||||||
)
|
db, version_id, measured_by, production_run_id,
|
||||||
if blocking is not None and blocking.subtask_id != subtask_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=(
|
|
||||||
"A measurement is out of tolerance and awaiting the supervisor: "
|
|
||||||
"authorise it or measure that quote again before going on"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
if blocking is not None and blocking.subtask_id != subtask_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||||
|
"authorise it or measure that quote again before going on"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rounded once, here, so the stored value is exactly the one the verdict
|
||||||
|
# above was computed on - calculate_pass_fail rounds internally too, but
|
||||||
|
# matching it here keeps the persisted row from disagreeing with itself.
|
||||||
|
value = round(value, 6)
|
||||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||||
|
|
||||||
measurement = Measurement(
|
measurement = Measurement(
|
||||||
@@ -266,6 +436,7 @@ async def save_measurement(
|
|||||||
input_method=input_method,
|
input_method=input_method,
|
||||||
input_duration_ms=input_duration_ms,
|
input_duration_ms=input_duration_ms,
|
||||||
production_run_id=production_run_id,
|
production_run_id=production_run_id,
|
||||||
|
vision_result_id=vision_result_id,
|
||||||
)
|
)
|
||||||
db.add(measurement)
|
db.add(measurement)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Live monitor of the open production runs (#57).
|
||||||
|
|
||||||
|
One query per table, whatever the number of runs: measurements and last events are
|
||||||
|
ranked per run with a window function and cut in the database, not in a loop.
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.api.monitor import MonitorEvent, MonitorRun, MonitorSubtask
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import production_service
|
||||||
|
|
||||||
|
# Recent measurements per run that feed counts and means (#57: last N).
|
||||||
|
MONITOR_WINDOW = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _in_tolerance(mean: float, ltl, utl) -> bool:
|
||||||
|
# A missing limit does not constrain.
|
||||||
|
if ltl is not None and mean < float(ltl):
|
||||||
|
return False
|
||||||
|
if utl is not None and mean > float(utl):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
|
||||||
|
runs = (await db.execute(
|
||||||
|
select(ProductionRun)
|
||||||
|
.where(ProductionRun.status.in_(("running", "paused")))
|
||||||
|
.order_by(ProductionRun.started_at)
|
||||||
|
)).scalars().all()
|
||||||
|
if not runs:
|
||||||
|
return []
|
||||||
|
run_ids = [r.id for r in runs]
|
||||||
|
|
||||||
|
# Operators: one query.
|
||||||
|
users = {
|
||||||
|
u.id: u for u in (await db.execute(
|
||||||
|
select(User).where(User.id.in_({r.operator_id for r in runs}))
|
||||||
|
)).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Last event per run: rank desc inside the run, keep rank 1.
|
||||||
|
ev_rank = func.row_number().over(
|
||||||
|
partition_by=ProductionEvent.run_id,
|
||||||
|
order_by=(ProductionEvent.created_at.desc(), ProductionEvent.id.desc()),
|
||||||
|
).label("rn")
|
||||||
|
ev_sub = select(
|
||||||
|
ProductionEvent.run_id.label("run_id"),
|
||||||
|
ProductionEvent.event_type.label("event_type"),
|
||||||
|
ProductionEvent.created_at.label("created_at"),
|
||||||
|
ProductionEvent.note.label("note"),
|
||||||
|
ev_rank,
|
||||||
|
).where(ProductionEvent.run_id.in_(run_ids)).subquery()
|
||||||
|
ev_rows = (await db.execute(
|
||||||
|
select(ev_sub.c.run_id, ev_sub.c.event_type, ev_sub.c.created_at, ev_sub.c.note)
|
||||||
|
.where(ev_sub.c.rn == 1)
|
||||||
|
)).all()
|
||||||
|
last_event = {r.run_id: r for r in ev_rows}
|
||||||
|
|
||||||
|
# Last N measurements per run, joined to their subtask for the limits.
|
||||||
|
m_rank = func.row_number().over(
|
||||||
|
partition_by=Measurement.production_run_id,
|
||||||
|
order_by=(Measurement.measured_at.desc(), Measurement.id.desc()),
|
||||||
|
).label("rn")
|
||||||
|
m_sub = select(
|
||||||
|
Measurement.production_run_id.label("run_id"),
|
||||||
|
Measurement.subtask_id.label("subtask_id"),
|
||||||
|
Measurement.value.label("value"),
|
||||||
|
Measurement.pass_fail.label("pass_fail"),
|
||||||
|
m_rank,
|
||||||
|
).where(Measurement.production_run_id.in_(run_ids)).subquery()
|
||||||
|
m_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
m_sub.c.run_id, m_sub.c.subtask_id, m_sub.c.value, m_sub.c.pass_fail,
|
||||||
|
RecipeSubtask.description, RecipeSubtask.unit,
|
||||||
|
RecipeSubtask.ltl, RecipeSubtask.utl,
|
||||||
|
)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.id == m_sub.c.subtask_id)
|
||||||
|
.where(m_sub.c.rn <= MONITOR_WINDOW)
|
||||||
|
)).all()
|
||||||
|
by_run = defaultdict(list)
|
||||||
|
for row in m_rows:
|
||||||
|
by_run[row.run_id].append(row)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for run in runs:
|
||||||
|
rows = by_run.get(run.id, [])
|
||||||
|
per_sub: dict[int, list] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
per_sub[row.subtask_id].append(row)
|
||||||
|
subtasks = []
|
||||||
|
for sid, group in sorted(per_sub.items()):
|
||||||
|
mean = sum(float(g.value) for g in group) / len(group)
|
||||||
|
first = group[0]
|
||||||
|
subtasks.append(MonitorSubtask(
|
||||||
|
subtask_id=sid, description=first.description, unit=first.unit,
|
||||||
|
count=len(group), mean=round(mean, 6),
|
||||||
|
ltl=float(first.ltl) if first.ltl is not None else None,
|
||||||
|
utl=float(first.utl) if first.utl is not None else None,
|
||||||
|
in_tolerance=_in_tolerance(mean, first.ltl, first.utl),
|
||||||
|
))
|
||||||
|
timer = production_service.describe(run)
|
||||||
|
ev = last_event.get(run.id)
|
||||||
|
operator = users.get(run.operator_id)
|
||||||
|
result.append(MonitorRun(
|
||||||
|
id=run.id,
|
||||||
|
station_code=run.station.code, station_name=run.station.name,
|
||||||
|
recipe_code=run.recipe.code, recipe_name=run.recipe.name,
|
||||||
|
version_number=run.version.version_number,
|
||||||
|
operator=(operator.display_name or operator.username) if operator else "",
|
||||||
|
status=run.status,
|
||||||
|
started_at=run.started_at,
|
||||||
|
duration_seconds=max(0, int((timer["server_time"] - run.started_at).total_seconds())),
|
||||||
|
cycle_count=run.cycle_count,
|
||||||
|
seconds_to_next_measurement=timer["seconds_to_next_measurement"],
|
||||||
|
overdue=timer["overdue"],
|
||||||
|
last_event=MonitorEvent(
|
||||||
|
event_type=ev.event_type, created_at=ev.created_at, note=ev.note,
|
||||||
|
) if ev else None,
|
||||||
|
measured=len(rows),
|
||||||
|
pass_count=sum(1 for g in rows if g.pass_fail == "pass"),
|
||||||
|
warning_count=sum(1 for g in rows if g.pass_fail == "warning"),
|
||||||
|
fail_count=sum(1 for g in rows if g.pass_fail == "fail"),
|
||||||
|
subtasks=subtasks,
|
||||||
|
server_time=timer["server_time"],
|
||||||
|
))
|
||||||
|
return result
|
||||||
@@ -9,9 +9,12 @@ The CSV uses the delimiters configured in system_settings, the same ones the man
|
|||||||
export honours, so a shop floor set to the Italian convention (';' and ',') gets
|
export honours, so a shop floor set to the Italian convention (';' and ',') gets
|
||||||
files that open correctly in their Excel.
|
files that open correctly in their Excel.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from src.backend.utils.tempo import iso_utc, to_local, utcnow
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
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.station import Station
|
||||||
from src.backend.models.orm.task import RecipeSubtask
|
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
|
# Written under the upload directory: it is the volume that is already mounted and
|
||||||
# backed up, so the files survive a container being replaced.
|
# backed up, so the files survive a container being replaced.
|
||||||
EXPORT_SUBDIR = "statistics"
|
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 = [
|
HEADER = [
|
||||||
"production_run_id",
|
"production_run_id",
|
||||||
"station_code",
|
"station_code",
|
||||||
@@ -75,8 +106,17 @@ async def _csv_format(db: AsyncSession) -> tuple[str, str]:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
decimal = decimal_row.scalar_one_or_none()
|
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 (
|
return (
|
||||||
delimiter.setting_value if delimiter else ",",
|
delimiter_value,
|
||||||
decimal.setting_value if decimal else ".",
|
decimal.setting_value if decimal else ".",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,15 +149,15 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
|||||||
for measurement, subtask in rows:
|
for measurement, subtask in rows:
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
run.id,
|
run.id,
|
||||||
station.code if station else "",
|
neutralise_csv_cell(station.code) if station else "",
|
||||||
recipe.code if recipe else "",
|
neutralise_csv_cell(recipe.code) if recipe else "",
|
||||||
measurement.version_id,
|
measurement.version_id,
|
||||||
measurement.lot_number or "",
|
neutralise_csv_cell(measurement.lot_number or ""),
|
||||||
measurement.serial_number or "",
|
neutralise_csv_cell(measurement.serial_number or ""),
|
||||||
measurement.id,
|
measurement.id,
|
||||||
measurement.subtask_id,
|
measurement.subtask_id,
|
||||||
subtask.marker_number if subtask else "",
|
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(subtask.nominal) if subtask else "",
|
||||||
number(measurement.value),
|
number(measurement.value),
|
||||||
number(measurement.deviation),
|
number(measurement.deviation),
|
||||||
@@ -130,9 +170,9 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
|||||||
measurement.input_method,
|
measurement.input_method,
|
||||||
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
||||||
measurement.measured_by,
|
measurement.measured_by,
|
||||||
measurement.measured_at.isoformat() if measurement.measured_at else "",
|
iso_utc(measurement.measured_at),
|
||||||
measurement.supervisor_id or "",
|
measurement.supervisor_id or "",
|
||||||
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
|
iso_utc(measurement.authorised_at),
|
||||||
])
|
])
|
||||||
|
|
||||||
return output.getvalue()
|
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 = Path(settings.upload_path) / EXPORT_SUBDIR
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
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"
|
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,
|
# utf-8-sig: the BOM is what makes Excel read accented characters correctly,
|
||||||
# and these files are opened in Excel on the shop floor.
|
# and these files are opened in Excel on the shop floor.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ the countdown would be off by the UTC offset. The number is the contract; the
|
|||||||
timestamp is only there for the audit trail.
|
timestamp is only there for the audit trail.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -23,7 +24,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.backend.models.api.production import ProductionRunCreate
|
from src.backend.models.api.production import ProductionRunCreate
|
||||||
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from src.backend.models.orm.station import Station
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from src.backend.models.orm.task import MEASURING_TASK_TYPES, RecipeTask
|
from src.backend.models.orm.task import MEASURING_TASK_TYPES, RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.services import (
|
from src.backend.services import (
|
||||||
@@ -34,10 +35,10 @@ from src.backend.services import (
|
|||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
"""Single source of time for runs.
|
"""Single source of time for runs.
|
||||||
|
|
||||||
Naive local time, matching what the database writes for ``func.now()`` defaults,
|
Naive UTC, matching what the database writes for ``func.now()`` defaults,
|
||||||
so a run's started_at and its deadline are comparable.
|
so a run's started_at and its deadline are comparable.
|
||||||
"""
|
"""
|
||||||
return datetime.now()
|
return utcnow()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -106,6 +107,29 @@ async def get_station_by_code(db: AsyncSession, code: str) -> Station:
|
|||||||
return station
|
return station
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_recipe_assigned_to_station(
|
||||||
|
db: AsyncSession, station_id: int, recipe_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""A configured station only runs what it is assigned; an unconfigured one is free.
|
||||||
|
|
||||||
|
Mirrors station_service.list_station_recipes, the read side an operator's
|
||||||
|
recipe list is already filtered by: a station with at least one assignment
|
||||||
|
restricts to those recipes, one with none has not been set up to filter at
|
||||||
|
all and stays open to any active recipe.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(StationRecipeAssignment.recipe_id).where(
|
||||||
|
StationRecipeAssignment.station_id == station_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assigned_ids = set(result.scalars().all())
|
||||||
|
if assigned_ids and recipe_id not in assigned_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="This recipe is not assigned to this station",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||||
"""Ids of the measurement tasks of a version, in the order they are executed.
|
"""Ids of the measurement tasks of a version, in the order they are executed.
|
||||||
|
|
||||||
@@ -121,7 +145,21 @@ async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
|||||||
select(RecipeTask.id)
|
select(RecipeTask.id)
|
||||||
.where(
|
.where(
|
||||||
RecipeTask.version_id == version_id,
|
RecipeTask.version_id == version_id,
|
||||||
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
|
# #53: il ciclo lo dichiara il task (in_loop), non piu' il solo tipo.
|
||||||
|
RecipeTask.in_loop.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def close_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||||
|
"""Ids dei task da compilare alla fine produzione (at_close), in ordine (#53)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeTask.id)
|
||||||
|
.where(
|
||||||
|
RecipeTask.version_id == version_id,
|
||||||
|
RecipeTask.at_close.is_(True),
|
||||||
)
|
)
|
||||||
.order_by(RecipeTask.order_index, RecipeTask.id)
|
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||||
)
|
)
|
||||||
@@ -199,6 +237,12 @@ async def open_run(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
|
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
|
||||||
)
|
)
|
||||||
|
if not recipe.active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Cannot start a production run with an inactive recipe",
|
||||||
|
)
|
||||||
|
await _require_recipe_assigned_to_station(db, station.id, recipe.id)
|
||||||
|
|
||||||
if data.version_id is not None:
|
if data.version_id is not None:
|
||||||
version_row = await db.execute(
|
version_row = await db.execute(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
|||||||
from src.backend.models.orm.setting import RecipeVersionAudit
|
from src.backend.models.orm.setting import RecipeVersionAudit
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage
|
||||||
from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
||||||
|
|
||||||
|
|
||||||
@@ -34,13 +35,34 @@ HEADER_FIELDS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# image_path and measurement_interval_minutes are nullable columns where the
|
||||||
|
# caller may deliberately want to clear the value (send null), not merely
|
||||||
|
# leave it untouched by not mentioning the field at all. A plain
|
||||||
|
# "getattr(...) is not None" check cannot tell those two cases apart -
|
||||||
|
# once set, neither field could ever be cleared again (#37.3). The other
|
||||||
|
# header fields keep that simpler check: booleans are always sent by the
|
||||||
|
# client (never null) and clearing name/description to null is not a case
|
||||||
|
# this endpoint needs to support.
|
||||||
|
_NULLABLE_HEADER_FIELDS = ("image_path", "measurement_interval_minutes")
|
||||||
|
|
||||||
|
|
||||||
def _header_fields(data: RecipeUpdate) -> dict:
|
def _header_fields(data: RecipeUpdate) -> dict:
|
||||||
"""The header columns the caller actually asked to change."""
|
"""The header columns the caller actually asked to change.
|
||||||
return {
|
|
||||||
field: getattr(data, field)
|
For image_path/measurement_interval_minutes, "asked to change" includes
|
||||||
for field in HEADER_FIELDS
|
explicitly setting it to null - told apart from "field absent from the
|
||||||
if getattr(data, field) is not None
|
request" via model_fields_set, since RecipeUpdate defaults both to None.
|
||||||
}
|
"""
|
||||||
|
result = {}
|
||||||
|
for field in HEADER_FIELDS:
|
||||||
|
if field in _NULLABLE_HEADER_FIELDS:
|
||||||
|
if field in data.model_fields_set:
|
||||||
|
result[field] = getattr(data, field)
|
||||||
|
else:
|
||||||
|
value = getattr(data, field)
|
||||||
|
if value is not None:
|
||||||
|
result[field] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
|
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
|
||||||
@@ -88,10 +110,43 @@ async def _copy_tasks_to_version(
|
|||||||
file_path=task.file_path,
|
file_path=task.file_path,
|
||||||
file_type=task.file_type,
|
file_type=task.file_type,
|
||||||
annotations_json=task.annotations_json,
|
annotations_json=task.annotations_json,
|
||||||
|
# Carried over explicitly, same reasoning as task_type above: a
|
||||||
|
# copy-on-write version that dropped the camera graph would leave
|
||||||
|
# a camera_measure task unable to execute from the moment a
|
||||||
|
# recipe with measurements is next edited.
|
||||||
|
vision_json=task.vision_json,
|
||||||
|
# Cartella immutabile: basta il riferimento (#46).
|
||||||
|
vision_recipe_dir=task.vision_recipe_dir,
|
||||||
|
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
|
||||||
|
in_loop=task.in_loop,
|
||||||
|
at_close=task.at_close,
|
||||||
|
fields=task.fields,
|
||||||
)
|
)
|
||||||
db.add(new_task)
|
db.add(new_task)
|
||||||
await db.flush() # get new_task.id
|
await db.flush() # get new_task.id
|
||||||
|
|
||||||
|
# Reference images (#38.6): the copied task points at the same files
|
||||||
|
# on disk as the source - same `path`, same acquisition metadata. A
|
||||||
|
# graph composed against a reference must keep working after the
|
||||||
|
# recipe is next edited, exactly like task_type/vision_json above.
|
||||||
|
# The file itself is only ever removed once no row references its
|
||||||
|
# path any more (see vision_service.file_still_referenced).
|
||||||
|
ref_images = (await db.execute(
|
||||||
|
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
|
||||||
|
)).scalars().all()
|
||||||
|
for ref in ref_images:
|
||||||
|
db.add(VisionReferenceImage(
|
||||||
|
task_id=new_task.id,
|
||||||
|
path=ref.path,
|
||||||
|
station_id=ref.station_id,
|
||||||
|
device_code=ref.device_code,
|
||||||
|
calibration_snapshot=ref.calibration_snapshot,
|
||||||
|
engine_version=ref.engine_version,
|
||||||
|
note=ref.note,
|
||||||
|
expected_json=ref.expected_json,
|
||||||
|
acquired_at=ref.acquired_at,
|
||||||
|
))
|
||||||
|
|
||||||
for sub in task.subtasks:
|
for sub in task.subtasks:
|
||||||
new_sub = RecipeSubtask(
|
new_sub = RecipeSubtask(
|
||||||
task_id=new_task.id,
|
task_id=new_task.id,
|
||||||
@@ -105,6 +160,7 @@ async def _copy_tasks_to_version(
|
|||||||
ltl=sub.ltl,
|
ltl=sub.ltl,
|
||||||
unit=sub.unit,
|
unit=sub.unit,
|
||||||
image_path=sub.image_path,
|
image_path=sub.image_path,
|
||||||
|
vision_output=sub.vision_output,
|
||||||
)
|
)
|
||||||
db.add(new_sub)
|
db.add(new_sub)
|
||||||
|
|
||||||
@@ -203,6 +259,78 @@ async def create_recipe(
|
|||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def duplicate_recipe(
|
||||||
|
db: AsyncSession,
|
||||||
|
recipe_id: int,
|
||||||
|
new_code: str,
|
||||||
|
new_name: str,
|
||||||
|
user: User,
|
||||||
|
) -> Recipe:
|
||||||
|
"""Duplicate a recipe (#52): new code/name, v1 with the current version's tasks.
|
||||||
|
|
||||||
|
The source recipe is left untouched.
|
||||||
|
"""
|
||||||
|
source = await _get_recipe_or_404(db, recipe_id)
|
||||||
|
|
||||||
|
existing = await db.execute(select(Recipe).where(Recipe.code == new_code))
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Recipe code '{new_code}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Same header fields create_recipe persists, taken from the source recipe
|
||||||
|
recipe = Recipe(
|
||||||
|
code=new_code,
|
||||||
|
name=new_name,
|
||||||
|
description=source.description,
|
||||||
|
image_path=source.image_path,
|
||||||
|
measurement_interval_minutes=source.measurement_interval_minutes,
|
||||||
|
requires_lot=source.requires_lot,
|
||||||
|
requires_serial=source.requires_serial,
|
||||||
|
allow_manual_input=source.allow_manual_input,
|
||||||
|
created_by=user.id,
|
||||||
|
)
|
||||||
|
db.add(recipe)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
version = RecipeVersion(
|
||||||
|
recipe_id=recipe.id,
|
||||||
|
version_number=1,
|
||||||
|
is_current=True,
|
||||||
|
created_by=user.id,
|
||||||
|
change_notes=f"Duplicated from {source.code}",
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Reuse the existing deep copy of tasks/subtasks from the current version
|
||||||
|
source_version = await _get_current_version(db, source.id)
|
||||||
|
if source_version is not None:
|
||||||
|
await _copy_tasks_to_version(db, source_version, version)
|
||||||
|
|
||||||
|
await _write_audit(
|
||||||
|
db,
|
||||||
|
recipe_id=recipe.id,
|
||||||
|
old_version_id=None,
|
||||||
|
new_version_id=version.id,
|
||||||
|
changed_by=user.id,
|
||||||
|
change_type="CREATE",
|
||||||
|
change_reason=f"Duplicated from recipe {source.code}",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Recipe)
|
||||||
|
.where(Recipe.id == recipe.id)
|
||||||
|
.options(
|
||||||
|
selectinload(Recipe.versions)
|
||||||
|
.selectinload(RecipeVersion.tasks)
|
||||||
|
.selectinload(RecipeTask.subtasks)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
async def create_new_version(
|
async def create_new_version(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
@@ -419,6 +547,7 @@ async def update_current_version(
|
|||||||
default_task = RecipeTask(
|
default_task = RecipeTask(
|
||||||
version_id=current.id,
|
version_id=current.id,
|
||||||
order_index=0,
|
order_index=0,
|
||||||
|
task_type="drawing",
|
||||||
title="Technical Drawing",
|
title="Technical Drawing",
|
||||||
file_path=data.file_path,
|
file_path=data.file_path,
|
||||||
file_type=data.file_type or (
|
file_type=data.file_type or (
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from src.backend.utils.tempo import to_local, utcnow
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
@@ -23,6 +26,23 @@ from src.backend.services.spc_service import (
|
|||||||
compute_summary,
|
compute_summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Kaleido 1.x aggiunge alla pagina di render uno <script> MathJax dal CDN (cdnjs) anche se i grafici
|
||||||
|
# non usano LaTeX: senza Internet (stand, PC di produzione) ogni grafico aspetta il timeout di rete
|
||||||
|
# (~19 s con rete che scarta i pacchetti, TieMeasureFlow#78). I grafici qui sotto non hanno formule:
|
||||||
|
# si passa a Kaleido un file locale vuoto al posto del CDN. Il file sta nella cartella temporanea.
|
||||||
|
def _mathjax_vuoto() -> str:
|
||||||
|
"""Percorso di un .js vuoto da usare come MathJax: nessuna richiesta di rete per i grafici."""
|
||||||
|
path = Path(tempfile.gettempdir()) / "tmf-mathjax-vuoto.js"
|
||||||
|
if not path.exists():
|
||||||
|
path.write_text("/* MathJax non usato: grafici senza LaTeX */", encoding="utf-8")
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
pio.defaults.mathjax = _mathjax_vuoto()
|
||||||
|
except (AttributeError, OSError): # plotly vecchio senza pio.defaults o temp non scrivibile: resta il CDN
|
||||||
|
pass
|
||||||
|
|
||||||
# Jinja2 environment for report templates
|
# Jinja2 environment for report templates
|
||||||
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
|
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
|
||||||
_jinja_env = Environment(
|
_jinja_env = Environment(
|
||||||
@@ -31,6 +51,11 @@ _jinja_env = Environment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _giorno_locale(dt: datetime) -> str:
|
||||||
|
"""UTC naive filter bound -> local calendar day (YYYY-MM-DD)."""
|
||||||
|
return to_local(dt).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
async def _query_measurements(
|
async def _query_measurements(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
@@ -41,6 +66,7 @@ async def _query_measurements(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None, # #58
|
||||||
) -> list[Measurement]:
|
) -> list[Measurement]:
|
||||||
"""Query measurements with common filters (duplicated from statistics router)."""
|
"""Query measurements with common filters (duplicated from statistics router)."""
|
||||||
filters = []
|
filters = []
|
||||||
@@ -66,6 +92,9 @@ async def _query_measurements(
|
|||||||
if serial_number is not None:
|
if serial_number is not None:
|
||||||
filters.append(Measurement.serial_number == serial_number)
|
filters.append(Measurement.serial_number == serial_number)
|
||||||
|
|
||||||
|
if production_run_id is not None:
|
||||||
|
filters.append(Measurement.production_run_id == production_run_id)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Measurement)
|
select(Measurement)
|
||||||
.where(and_(*filters) if filters else True)
|
.where(and_(*filters) if filters else True)
|
||||||
@@ -204,6 +233,11 @@ def _generate_control_chart_svg(chart_data, subtask_info: dict | None) -> str:
|
|||||||
return svg_bytes.decode("utf-8")
|
return svg_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_pdf(html_content: str) -> bytes:
|
||||||
|
"""Blocking WeasyPrint render — always call via asyncio.to_thread."""
|
||||||
|
return HTML(string=html_content).write_pdf()
|
||||||
|
|
||||||
|
|
||||||
def _generate_histogram_svg(hist_data, subtask_info: dict | None) -> str:
|
def _generate_histogram_svg(hist_data, subtask_info: dict | None) -> str:
|
||||||
"""Generate histogram SVG using Plotly + Kaleido."""
|
"""Generate histogram SVG using Plotly + Kaleido."""
|
||||||
if not hist_data.bins:
|
if not hist_data.bins:
|
||||||
@@ -269,6 +303,7 @@ async def generate_spc_report(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None, # #58
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Generate SPC PDF report and return bytes.
|
"""Generate SPC PDF report and return bytes.
|
||||||
|
|
||||||
@@ -278,6 +313,7 @@ async def generate_spc_report(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Compute SPC data
|
# 2. Compute SPC data
|
||||||
@@ -300,9 +336,13 @@ async def generate_spc_report(
|
|||||||
)
|
)
|
||||||
histogram = compute_histogram(values)
|
histogram = compute_histogram(values)
|
||||||
|
|
||||||
# 3. Generate SVG charts
|
# 3. Generate SVG charts (kaleido shells out and blocks: off the event loop)
|
||||||
control_chart_svg = _generate_control_chart_svg(control_chart, subtask_info)
|
control_chart_svg = await asyncio.to_thread(
|
||||||
histogram_svg = _generate_histogram_svg(histogram, subtask_info)
|
_generate_control_chart_svg, control_chart, subtask_info
|
||||||
|
)
|
||||||
|
histogram_svg = await asyncio.to_thread(
|
||||||
|
_generate_histogram_svg, histogram, subtask_info
|
||||||
|
)
|
||||||
|
|
||||||
# 4. Get company info and recipe info
|
# 4. Get company info and recipe info
|
||||||
company = await _get_company_info(db)
|
company = await _get_company_info(db)
|
||||||
@@ -313,13 +353,15 @@ async def generate_spc_report(
|
|||||||
if version_id:
|
if version_id:
|
||||||
filters_desc.append(f"Version: {version_id}")
|
filters_desc.append(f"Version: {version_id}")
|
||||||
if date_from:
|
if date_from:
|
||||||
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
filters_desc.append(f"From: {_giorno_locale(date_from)}")
|
||||||
if date_to:
|
if date_to:
|
||||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
||||||
if lot_number:
|
if lot_number:
|
||||||
filters_desc.append(f"Lot: {lot_number}")
|
filters_desc.append(f"Lot: {lot_number}")
|
||||||
if serial_number:
|
if serial_number:
|
||||||
filters_desc.append(f"Serial: {serial_number}")
|
filters_desc.append(f"Serial: {serial_number}")
|
||||||
|
if production_run_id is not None:
|
||||||
|
filters_desc.append(f"Run: {production_run_id}") # #58
|
||||||
|
|
||||||
# 6. Render HTML template
|
# 6. Render HTML template
|
||||||
template = _jinja_env.get_template("spc_report.html")
|
template = _jinja_env.get_template("spc_report.html")
|
||||||
@@ -332,12 +374,12 @@ async def generate_spc_report(
|
|||||||
control_chart_svg=control_chart_svg,
|
control_chart_svg=control_chart_svg,
|
||||||
histogram_svg=histogram_svg,
|
histogram_svg=histogram_svg,
|
||||||
filters_desc=filters_desc,
|
filters_desc=filters_desc,
|
||||||
generated_at=datetime.now(),
|
generated_at=to_local(utcnow()),
|
||||||
n_measurements=len(measurements),
|
n_measurements=len(measurements),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 7. Convert HTML to PDF
|
# 7. Convert HTML to PDF (WeasyPrint blocks: off the event loop)
|
||||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
|
||||||
return pdf_bytes
|
return pdf_bytes
|
||||||
|
|
||||||
|
|
||||||
@@ -351,12 +393,14 @@ async def generate_measurement_report(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None, # #58
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Generate measurement table PDF report and return bytes."""
|
"""Generate measurement table PDF report and return bytes."""
|
||||||
# 1. Query measurements
|
# 1. Query measurements
|
||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Get recipe info
|
# 2. Get recipe info
|
||||||
@@ -385,7 +429,7 @@ async def generate_measurement_report(
|
|||||||
"value": f"{float(m.value):.4f}",
|
"value": f"{float(m.value):.4f}",
|
||||||
"unit": st_info.get("unit", "mm"),
|
"unit": st_info.get("unit", "mm"),
|
||||||
"pass_fail": m.pass_fail,
|
"pass_fail": m.pass_fail,
|
||||||
"measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"),
|
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
|
||||||
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
|
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
|
||||||
"lot_number": m.lot_number or "—",
|
"lot_number": m.lot_number or "—",
|
||||||
"serial_number": m.serial_number or "—",
|
"serial_number": m.serial_number or "—",
|
||||||
@@ -404,13 +448,15 @@ async def generate_measurement_report(
|
|||||||
if version_id:
|
if version_id:
|
||||||
filters_desc.append(f"Version: {version_id}")
|
filters_desc.append(f"Version: {version_id}")
|
||||||
if date_from:
|
if date_from:
|
||||||
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
filters_desc.append(f"From: {_giorno_locale(date_from)}")
|
||||||
if date_to:
|
if date_to:
|
||||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
||||||
if lot_number:
|
if lot_number:
|
||||||
filters_desc.append(f"Lot: {lot_number}")
|
filters_desc.append(f"Lot: {lot_number}")
|
||||||
if serial_number:
|
if serial_number:
|
||||||
filters_desc.append(f"Serial: {serial_number}")
|
filters_desc.append(f"Serial: {serial_number}")
|
||||||
|
if production_run_id is not None:
|
||||||
|
filters_desc.append(f"Run: {production_run_id}") # #58
|
||||||
|
|
||||||
# 7. Render template
|
# 7. Render template
|
||||||
template = _jinja_env.get_template("measurement_report.html")
|
template = _jinja_env.get_template("measurement_report.html")
|
||||||
@@ -420,10 +466,10 @@ async def generate_measurement_report(
|
|||||||
rows=rows,
|
rows=rows,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
filters_desc=filters_desc,
|
filters_desc=filters_desc,
|
||||||
generated_at=datetime.now(),
|
generated_at=to_local(utcnow()),
|
||||||
n_measurements=len(measurements),
|
n_measurements=len(measurements),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 8. Convert to PDF
|
# 8. Convert to PDF (WeasyPrint blocks: off the event loop)
|
||||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
|
||||||
return pdf_bytes
|
return pdf_bytes
|
||||||
|
|||||||
@@ -55,6 +55,27 @@ def compute_summary(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sigma_within(values: list[float]) -> float | None:
|
||||||
|
"""Estimate within-subgroup sigma from the moving range of individuals.
|
||||||
|
|
||||||
|
sigma_within = MRbar / d2, with d2 = 1.128 (moving range of consecutive
|
||||||
|
pairs, i.e. subgroup size 2). `values` must be in chronological order
|
||||||
|
(the order in which measurements arrived) — the moving range is not
|
||||||
|
meaningful on a re-sorted series.
|
||||||
|
|
||||||
|
Returns None when fewer than 2 values are available, or when MRbar == 0
|
||||||
|
(no variation between consecutive points: the index would be undefined).
|
||||||
|
"""
|
||||||
|
n = len(values)
|
||||||
|
if n < 2:
|
||||||
|
return None
|
||||||
|
moving_ranges = [abs(values[i] - values[i - 1]) for i in range(1, n)]
|
||||||
|
mr_bar = stats.mean(moving_ranges)
|
||||||
|
if mr_bar == 0:
|
||||||
|
return None
|
||||||
|
return mr_bar / 1.128
|
||||||
|
|
||||||
|
|
||||||
def compute_capability(
|
def compute_capability(
|
||||||
values: list[float],
|
values: list[float],
|
||||||
utl: float | None,
|
utl: float | None,
|
||||||
@@ -63,12 +84,18 @@ def compute_capability(
|
|||||||
) -> CapabilityData:
|
) -> CapabilityData:
|
||||||
"""Compute capability indices Cp, Cpk, Pp, Ppk.
|
"""Compute capability indices Cp, Cpk, Pp, Ppk.
|
||||||
|
|
||||||
|
`values` must be in chronological order (arrival order), since Cp/Cpk are
|
||||||
|
derived from the moving range between consecutive points:
|
||||||
|
|
||||||
|
- sigma_within = MRbar / 1.128 (MRbar = mean of |x_i - x_(i-1)|)
|
||||||
- Cp = (UTL - LTL) / (6 * sigma_within)
|
- Cp = (UTL - LTL) / (6 * sigma_within)
|
||||||
- Cpk = min((UTL - mean) / (3 * sigma), (mean - LTL) / (3 * sigma))
|
- Cpk = min((UTL - mean) / (3 * sigma_within), (mean - LTL) / (3 * sigma_within))
|
||||||
- Pp/Ppk: same formulas using population std dev (same data, no subgrouping)
|
- Pp/Ppk: same formulas using the overall sample std dev (n-1, `stats.stdev`)
|
||||||
|
of the whole series — this is the "process performance" pair and is
|
||||||
|
allowed to be wider than Cp/Cpk when there is drift between subgroups.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
values: Measured values.
|
values: Measured values, in chronological order.
|
||||||
utl: Upper Tolerance Limit (None if not defined).
|
utl: Upper Tolerance Limit (None if not defined).
|
||||||
ltl: Lower Tolerance Limit (None if not defined).
|
ltl: Lower Tolerance Limit (None if not defined).
|
||||||
nominal: Nominal value (None if not defined).
|
nominal: Nominal value (None if not defined).
|
||||||
@@ -81,39 +108,39 @@ def compute_capability(
|
|||||||
return CapabilityData(
|
return CapabilityData(
|
||||||
cp=None, cpk=None, pp=None, ppk=None,
|
cp=None, cpk=None, pp=None, ppk=None,
|
||||||
mean=values[0] if n == 1 else 0.0,
|
mean=values[0] if n == 1 else 0.0,
|
||||||
std_dev=0.0, n=n,
|
std_dev=0.0, std_dev_within=None, n=n,
|
||||||
utl=utl, ltl=ltl, nominal=nominal,
|
utl=utl, ltl=ltl, nominal=nominal,
|
||||||
)
|
)
|
||||||
|
|
||||||
mean = stats.mean(values)
|
mean = stats.mean(values)
|
||||||
# Population std dev for Pp/Ppk
|
# Overall sample std dev (n-1) for Pp/Ppk
|
||||||
std_dev_pop = stats.pstdev(values)
|
|
||||||
# Sample std dev for Cp/Cpk
|
|
||||||
std_dev_sample = stats.stdev(values)
|
std_dev_sample = stats.stdev(values)
|
||||||
|
# Within-subgroup sigma (moving range) for Cp/Cpk
|
||||||
|
sigma_within = _sigma_within(values)
|
||||||
|
|
||||||
cp = cpk = pp = ppk = None
|
cp = cpk = pp = ppk = None
|
||||||
|
|
||||||
if utl is not None and ltl is not None and std_dev_sample > 0:
|
if sigma_within is not None:
|
||||||
cp = round((utl - ltl) / (6 * std_dev_sample), 4)
|
if utl is not None and ltl is not None:
|
||||||
|
cp = round((utl - ltl) / (6 * sigma_within), 4)
|
||||||
|
|
||||||
if std_dev_sample > 0:
|
|
||||||
cpk_values = []
|
cpk_values = []
|
||||||
if utl is not None:
|
if utl is not None:
|
||||||
cpk_values.append((utl - mean) / (3 * std_dev_sample))
|
cpk_values.append((utl - mean) / (3 * sigma_within))
|
||||||
if ltl is not None:
|
if ltl is not None:
|
||||||
cpk_values.append((mean - ltl) / (3 * std_dev_sample))
|
cpk_values.append((mean - ltl) / (3 * sigma_within))
|
||||||
if cpk_values:
|
if cpk_values:
|
||||||
cpk = round(min(cpk_values), 4)
|
cpk = round(min(cpk_values), 4)
|
||||||
|
|
||||||
if utl is not None and ltl is not None and std_dev_pop > 0:
|
if std_dev_sample > 0:
|
||||||
pp = round((utl - ltl) / (6 * std_dev_pop), 4)
|
if utl is not None and ltl is not None:
|
||||||
|
pp = round((utl - ltl) / (6 * std_dev_sample), 4)
|
||||||
|
|
||||||
if std_dev_pop > 0:
|
|
||||||
ppk_values = []
|
ppk_values = []
|
||||||
if utl is not None:
|
if utl is not None:
|
||||||
ppk_values.append((utl - mean) / (3 * std_dev_pop))
|
ppk_values.append((utl - mean) / (3 * std_dev_sample))
|
||||||
if ltl is not None:
|
if ltl is not None:
|
||||||
ppk_values.append((mean - ltl) / (3 * std_dev_pop))
|
ppk_values.append((mean - ltl) / (3 * std_dev_sample))
|
||||||
if ppk_values:
|
if ppk_values:
|
||||||
ppk = round(min(ppk_values), 4)
|
ppk = round(min(ppk_values), 4)
|
||||||
|
|
||||||
@@ -121,6 +148,7 @@ def compute_capability(
|
|||||||
cp=cp, cpk=cpk, pp=pp, ppk=ppk,
|
cp=cp, cpk=cpk, pp=pp, ppk=ppk,
|
||||||
mean=round(mean, 6),
|
mean=round(mean, 6),
|
||||||
std_dev=round(std_dev_sample, 6),
|
std_dev=round(std_dev_sample, 6),
|
||||||
|
std_dev_within=round(sigma_within, 6) if sigma_within is not None else None,
|
||||||
n=n,
|
n=n,
|
||||||
utl=utl, ltl=ltl, nominal=nominal,
|
utl=utl, ltl=ltl, nominal=nominal,
|
||||||
)
|
)
|
||||||
@@ -135,10 +163,12 @@ def compute_control_chart(
|
|||||||
ltl: float | None,
|
ltl: float | None,
|
||||||
nominal: float | None,
|
nominal: float | None,
|
||||||
) -> ControlChartData:
|
) -> ControlChartData:
|
||||||
"""Compute control chart data with UCL/LCL and out-of-control detection.
|
"""Compute control chart data (individuals chart) with UCL/LCL and
|
||||||
|
out-of-control detection.
|
||||||
|
|
||||||
UCL = mean + 3*sigma
|
UCL = mean + 3*sigma_within = mean + 2.66*MRbar
|
||||||
LCL = mean - 3*sigma
|
LCL = mean - 3*sigma_within = mean - 2.66*MRbar
|
||||||
|
with sigma_within = MRbar/1.128 (see `_sigma_within`).
|
||||||
Out-of-control: points outside UCL/LCL.
|
Out-of-control: points outside UCL/LCL.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -160,10 +190,8 @@ def compute_control_chart(
|
|||||||
|
|
||||||
mean = stats.mean(values)
|
mean = stats.mean(values)
|
||||||
|
|
||||||
if n >= 2:
|
sigma_within = _sigma_within(values)
|
||||||
sigma = stats.stdev(values)
|
sigma = sigma_within if sigma_within is not None else 0.0
|
||||||
else:
|
|
||||||
sigma = 0.0
|
|
||||||
|
|
||||||
ucl = mean + 3 * sigma
|
ucl = mean + 3 * sigma
|
||||||
lcl = mean - 3 * sigma
|
lcl = mean - 3 * sigma
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ async def create_station(
|
|||||||
location=data.location,
|
location=data.location,
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
active=data.active,
|
active=data.active,
|
||||||
|
vision_worker_url=data.vision_worker_url, # #45
|
||||||
|
camera_url=data.camera_url, # #72
|
||||||
|
camera_source=data.camera_source, # #72
|
||||||
created_by=creator.id,
|
created_by=creator.id,
|
||||||
)
|
)
|
||||||
db.add(station)
|
db.add(station)
|
||||||
|
|||||||
@@ -0,0 +1,603 @@
|
|||||||
|
"""Route the image to the worker, map the outputs onto the quotes, save.
|
||||||
|
|
||||||
|
This module never imports VisionSuite - see test_vision_boundary.py. It speaks
|
||||||
|
to the worker over HTTP and stays ignorant of how a number was produced.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
|
from src.backend.services import measurement_service
|
||||||
|
|
||||||
|
# Worker responses whose status and reason are forwarded as-is rather than
|
||||||
|
# flattened into a generic 502 - see `_call_worker`.
|
||||||
|
_FORWARDED_WORKER_STATUSES = frozenset({
|
||||||
|
status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_measurable(value: object) -> bool:
|
||||||
|
"""A worker output counts as a quote only if it is a finite, non-bool number.
|
||||||
|
|
||||||
|
None (absent, or explicitly null in a malformed response), a bool (a
|
||||||
|
subclass of int), and NaN/inf all fail this - each one would otherwise
|
||||||
|
reach `float()` and either crash with a bare TypeError or silently save a
|
||||||
|
value nobody could have measured.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
isinstance(value, (int, float))
|
||||||
|
and not isinstance(value, bool)
|
||||||
|
and math.isfinite(value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_worker(
|
||||||
|
image_bytes: bytes,
|
||||||
|
graph: dict,
|
||||||
|
*,
|
||||||
|
recipe_dir: str | None = None,
|
||||||
|
calibration: dict | None = None,
|
||||||
|
worker_url: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""The one place that talks to the worker; the tests replace it.
|
||||||
|
|
||||||
|
The worker's fault must not become the API's fault (spec's edge-case
|
||||||
|
table: "il worker isola il guasto, l'API resta in piedi, il task
|
||||||
|
riporta l'errore"). httpx.TimeoutException (ConnectTimeout, ReadTimeout,
|
||||||
|
WriteTimeout, PoolTimeout) becomes 504; every other transport failure
|
||||||
|
(ConnectError, ReadError, RemoteProtocolError when the worker dies
|
||||||
|
mid-response, ...) becomes 502 - both would otherwise propagate past
|
||||||
|
this function and surface to the operator as a naked 500. The response
|
||||||
|
shape is validated here too: a proxy answering 200 with something that
|
||||||
|
is not the worker's JSON contract must not turn into a bare KeyError
|
||||||
|
further down.
|
||||||
|
"""
|
||||||
|
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
|
||||||
|
# si mandano, il worker usa i suoi default.
|
||||||
|
data = {"graph": json.dumps(graph)}
|
||||||
|
# worker della stazione (#45) o, se manca, il centrale; mai un ripiego dall'uno all'altro
|
||||||
|
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||||
|
if recipe_dir:
|
||||||
|
data["recipe_dir"] = recipe_dir
|
||||||
|
if calibration:
|
||||||
|
data["calibration"] = json.dumps(calibration)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{base}/run",
|
||||||
|
files={"image": ("frame.png", image_bytes, "image/png")},
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail=f"the vision worker did not answer in time: {exc} ({base})", # #45
|
||||||
|
) from exc
|
||||||
|
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker is unreachable: {exc} ({base})", # #45
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# The worker already gave this fault a name - forward its status and
|
||||||
|
# reason instead of flattening every non-200 into a generic 502: 422 is a
|
||||||
|
# bad request (undecodable image, malformed graph), 503 is "busy, try
|
||||||
|
# again" (VISION_MAX_CONCURRENCY exceeded), 504 is its own run timeout
|
||||||
|
# (VISION_RUN_TIMEOUT_S, always tighter than this client's own 120s so
|
||||||
|
# the worker answers first).
|
||||||
|
if response.status_code in _FORWARDED_WORKER_STATUSES:
|
||||||
|
try:
|
||||||
|
detail = response.json().get("detail", response.text)
|
||||||
|
except ValueError:
|
||||||
|
detail = response.text
|
||||||
|
raise HTTPException(status_code=response.status_code, detail=detail)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker refused the job: {response.text}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
report = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(report, dict):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: expected a JSON object",
|
||||||
|
)
|
||||||
|
missing = {"outputs", "failures", "engine_version"} - report.keys()
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=(
|
||||||
|
"the vision worker's answer was malformed: missing "
|
||||||
|
f"{', '.join(sorted(missing))}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
||||||
|
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
||||||
|
|
||||||
|
Returns {"ok", "errors", "outputs"}. A worker 422 (e.g. recipe_dir
|
||||||
|
refused) is folded into ok=False; transport faults become 502/504 as in
|
||||||
|
`_call_worker`. Replaced by the tests.
|
||||||
|
"""
|
||||||
|
data = {"graph": json.dumps(graph)}
|
||||||
|
if recipe_dir:
|
||||||
|
data["recipe_dir"] = recipe_dir
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{settings.vision_worker_url}/validate", data=data,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail=f"the vision worker did not answer in time: {exc}",
|
||||||
|
) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker is unreachable: {exc}",
|
||||||
|
) from exc
|
||||||
|
if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT:
|
||||||
|
try:
|
||||||
|
detail = response.json().get("detail", response.text)
|
||||||
|
except ValueError:
|
||||||
|
detail = response.text
|
||||||
|
errors = detail if isinstance(detail, list) else [str(detail)]
|
||||||
|
return {"ok": False, "errors": [str(e) for e in errors], "outputs": []}
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker refused the job: {response.text}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: not valid JSON",
|
||||||
|
) from exc
|
||||||
|
if not isinstance(body, dict) or "ok" not in body:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: missing ok",
|
||||||
|
)
|
||||||
|
body.setdefault("errors", [])
|
||||||
|
body.setdefault("outputs", [])
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
# Task types that run a vision graph (#46).
|
||||||
|
VISION_TASK_TYPES = ("camera_measure", "dxf_compare")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_join(root: Path, relative: str) -> Path:
|
||||||
|
"""root/relative, refusing absolute paths, '..' and anything leaving root."""
|
||||||
|
candidate = Path(relative)
|
||||||
|
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
|
||||||
|
raise ValueError(f"unsafe path: {relative}")
|
||||||
|
full = (root / candidate).resolve()
|
||||||
|
if not full.is_relative_to(root.resolve()):
|
||||||
|
raise ValueError(f"unsafe path: {relative}")
|
||||||
|
return full
|
||||||
|
|
||||||
|
|
||||||
|
def collect_meta_paths(node: object) -> list[str]:
|
||||||
|
"""Every `path` string found in any dict under the recipe's `meta`."""
|
||||||
|
found: list[str] = []
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key, value in node.items():
|
||||||
|
if key == "path" and isinstance(value, str):
|
||||||
|
found.append(value)
|
||||||
|
else:
|
||||||
|
found.extend(collect_meta_paths(value))
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
found.extend(collect_meta_paths(item))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def match_recipe_files(
|
||||||
|
recipe: dict, uploads: list[tuple[str, bytes]],
|
||||||
|
) -> tuple[dict[str, bytes], list[str]]:
|
||||||
|
"""Pair each uploaded file with a meta path by base name (#46).
|
||||||
|
|
||||||
|
Returns ({relative path: bytes}, errors). Errors list every upload that
|
||||||
|
has no name or matches no meta path, and every unsafe meta path. A meta
|
||||||
|
path left without a file is not an error here: /validate decides.
|
||||||
|
"""
|
||||||
|
paths = collect_meta_paths(recipe.get("meta", {}))
|
||||||
|
by_name: dict[str, str] = {}
|
||||||
|
for p in paths:
|
||||||
|
by_name.setdefault(Path(p.replace("\\", "/")).name, p)
|
||||||
|
placed: dict[str, bytes] = {}
|
||||||
|
errors: list[str] = []
|
||||||
|
for name, content in uploads:
|
||||||
|
if not name:
|
||||||
|
errors.append("a file has no name")
|
||||||
|
continue
|
||||||
|
target = by_name.get(Path(name.replace("\\", "/")).name)
|
||||||
|
if target is None:
|
||||||
|
errors.append(f"file {name!r} matches no path in the recipe's meta")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_safe_join(Path("."), target)
|
||||||
|
except ValueError:
|
||||||
|
errors.append(f"meta path {target!r} is not allowed")
|
||||||
|
continue
|
||||||
|
placed[target] = content
|
||||||
|
return placed, errors
|
||||||
|
|
||||||
|
|
||||||
|
def store_recipe_folder(
|
||||||
|
task_id: int, recipe: dict, files: dict[str, bytes],
|
||||||
|
) -> str:
|
||||||
|
"""Write the immutable recipe folder; return its path relative to uploads.
|
||||||
|
|
||||||
|
Folder name = first 16 hex of the recipe's sha256. If it already exists it
|
||||||
|
is left untouched (immutable: other task versions may reference it).
|
||||||
|
"""
|
||||||
|
canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":"))
|
||||||
|
# Il digest copre anche i file (path ordinato + sha256 del contenuto): la
|
||||||
|
# stessa ricetta con file diversi (es. primo upload senza il DXF) deve dare
|
||||||
|
# una cartella diversa, altrimenti quella incompleta resterebbe per sempre
|
||||||
|
# perche' immutabile (#46).
|
||||||
|
h = hashlib.sha256(canonical.encode("utf-8"))
|
||||||
|
for rel in sorted(files):
|
||||||
|
h.update(b"\0" + rel.encode("utf-8") + b"\0")
|
||||||
|
h.update(hashlib.sha256(files[rel]).digest())
|
||||||
|
digest = h.hexdigest()[:16]
|
||||||
|
relative = f"vision/recipes/{task_id}/{digest}"
|
||||||
|
folder = settings.upload_path / relative
|
||||||
|
if folder.exists():
|
||||||
|
return relative
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
(folder / "recipe.json").write_text(
|
||||||
|
json.dumps(recipe, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||||
|
)
|
||||||
|
for rel, content in files.items():
|
||||||
|
target = _safe_join(folder, rel)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(content)
|
||||||
|
return relative
|
||||||
|
|
||||||
|
|
||||||
|
def list_recipe_files(recipe_dir: str | None) -> list[str]:
|
||||||
|
"""Files of a recipe folder, relative to it (recipe.json excluded)."""
|
||||||
|
if not recipe_dir:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
folder = _safe_join(settings.upload_path, recipe_dir)
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
if not folder.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
p.relative_to(folder).as_posix()
|
||||||
|
for p in folder.rglob("*") if p.is_file() and p.name != "recipe.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_result_files(
|
||||||
|
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Save the executed image and its overlay JSON side by side (#46).
|
||||||
|
|
||||||
|
Returns (image_path, overlay_path), both relative to uploads.
|
||||||
|
"""
|
||||||
|
stem = uuid4().hex
|
||||||
|
base = Path("vision") / "results" / str(task_id)
|
||||||
|
(settings.upload_path / base).mkdir(parents=True, exist_ok=True)
|
||||||
|
image_rel = (base / f"{stem}.{image_ext}").as_posix()
|
||||||
|
overlay_rel = (base / f"{stem}.overlay.json").as_posix()
|
||||||
|
(settings.upload_path / image_rel).write_bytes(image_bytes)
|
||||||
|
(settings.upload_path / overlay_rel).write_text(json.dumps(overlay), encoding="utf-8")
|
||||||
|
return image_rel, overlay_rel
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_task(
|
||||||
|
db: AsyncSession,
|
||||||
|
task: RecipeTask,
|
||||||
|
image_bytes: bytes,
|
||||||
|
measured_by: int,
|
||||||
|
*,
|
||||||
|
version_id: int,
|
||||||
|
lot_number: str | None = None,
|
||||||
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None,
|
||||||
|
image_ext: str = "png",
|
||||||
|
) -> tuple[VisionResult, list[Measurement], dict]:
|
||||||
|
if task.task_type not in VISION_TASK_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"task type {task.task_type!r} is not a camera measurement",
|
||||||
|
)
|
||||||
|
|
||||||
|
version = (await db.execute(
|
||||||
|
select(RecipeVersion).where(RecipeVersion.id == version_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if version is None or not version.is_current:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="this task's recipe version is no longer current",
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe = await measurement_service.recipe_of_version(db, version_id)
|
||||||
|
if recipe is None or not recipe.active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="this recipe is not active",
|
||||||
|
)
|
||||||
|
|
||||||
|
run = None
|
||||||
|
if production_run_id is not None:
|
||||||
|
# 404 if the run does not exist, 422 if it belongs to another recipe,
|
||||||
|
# 409 if it is closed or paused - same rule a manual reading follows.
|
||||||
|
run = await measurement_service.validate_run_for_version(
|
||||||
|
db, production_run_id, version_id,
|
||||||
|
)
|
||||||
|
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||||
|
station_url = None
|
||||||
|
if run is not None:
|
||||||
|
station = (await db.execute(
|
||||||
|
select(Station).where(Station.id == run.station_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||||
|
|
||||||
|
if not task.vision_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="this task carries no vision graph",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ordered by marker_number - the number the operator sees on the annotated
|
||||||
|
# image and in reports (RecipeSubtask.__repr__, statistics.py, report_service.py
|
||||||
|
# all key off it). Which quote is saved first must never be an accident of
|
||||||
|
# how SQLite/MySQL happens to return rows.
|
||||||
|
quotes = (await db.execute(
|
||||||
|
select(RecipeSubtask)
|
||||||
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
.where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
.order_by(RecipeSubtask.marker_number)
|
||||||
|
)).scalars().all()
|
||||||
|
if not quotes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="no quote on this task names a vision output",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ruling R10: the gate is evaluated once per acquisition, not once per
|
||||||
|
# quote. `save_measurement`'s own gate models "moving on" between
|
||||||
|
# measurements taken one after another - a real later moment where the
|
||||||
|
# operator could leave a bad reading behind. A camera acquisition produces
|
||||||
|
# every quote of this task at the same instant: there is no "moving on"
|
||||||
|
# between them, so applying the sequential rule inside the batch would
|
||||||
|
# block on a fail this very request just saved. Check it once here,
|
||||||
|
# against the whole set this acquisition is about to produce, and pass
|
||||||
|
# enforce_tolerance_gate=False to every save_measurement call below.
|
||||||
|
#
|
||||||
|
# Checked before the worker runs (M3): a blocked operator finds out
|
||||||
|
# without burning a whole vision execution first - the worker is the
|
||||||
|
# expensive part, the gate check is not.
|
||||||
|
blocking = await measurement_service.pending_authorisation(
|
||||||
|
db, version_id, measured_by, production_run_id,
|
||||||
|
)
|
||||||
|
quote_ids = {q.id for q in quotes}
|
||||||
|
if blocking is not None and blocking.subtask_id not in quote_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||||
|
"authorise it or measure that quote again before going on"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
|
||||||
|
# passa `calibration`, la ricetta porta la sua (#46).
|
||||||
|
report = await _call_worker(
|
||||||
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
|
worker_url=station_url, # #45
|
||||||
|
)
|
||||||
|
outputs = report["outputs"]
|
||||||
|
|
||||||
|
# Every quote or none. A half-measured piece read as a whole one is worse
|
||||||
|
# than a piece not measured at all. A quote counts as produced only if its
|
||||||
|
# output is present *and* a finite, non-bool number: the in-process runner
|
||||||
|
# already drops NaN/inf itself (see src/vision/runner.py), but the worker
|
||||||
|
# is reached over HTTP - a different implementation of it, or a malformed
|
||||||
|
# response, could still hand back None or a non-finite float for a mapped
|
||||||
|
# key instead of omitting it outright.
|
||||||
|
missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))]
|
||||||
|
if missing:
|
||||||
|
failed = ", ".join(f["tool_name"] for f in report["failures"])
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=(
|
||||||
|
f"the graph did not produce {', '.join(missing)}"
|
||||||
|
+ (f" (tools that failed: {failed})" if failed else "")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
overlay = report.get("overlay") or []
|
||||||
|
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
|
||||||
|
result = VisionResult(
|
||||||
|
task_id=task.id,
|
||||||
|
image_path=image_path,
|
||||||
|
overlay_path=overlay_path,
|
||||||
|
engine_version=report["engine_version"],
|
||||||
|
# #45: dove e' girato davvero, e quale worker (copiato come lo snapshot)
|
||||||
|
executed_on="station" if station_url else "server",
|
||||||
|
station_id=run.station_id if run is not None else None,
|
||||||
|
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||||
|
graph_snapshot=task.vision_json,
|
||||||
|
duration_ms=report.get("duration_ms"),
|
||||||
|
)
|
||||||
|
db.add(result)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
saved: list[Measurement] = []
|
||||||
|
for quote in quotes:
|
||||||
|
saved.append(await measurement_service.save_measurement(
|
||||||
|
db,
|
||||||
|
subtask_id=quote.id,
|
||||||
|
version_id=version_id,
|
||||||
|
measured_by=measured_by,
|
||||||
|
value=float(outputs[quote.vision_output]),
|
||||||
|
lot_number=lot_number,
|
||||||
|
serial_number=serial_number,
|
||||||
|
input_method="camera",
|
||||||
|
production_run_id=production_run_id,
|
||||||
|
vision_result_id=result.id,
|
||||||
|
enforce_tolerance_gate=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
return result, saved, report
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
||||||
|
return settings.upload_path / reference.path
|
||||||
|
|
||||||
|
|
||||||
|
def remove_reference_file(reference: VisionReferenceImage) -> None:
|
||||||
|
"""Delete a reference image's file, if it is still there.
|
||||||
|
|
||||||
|
Called by the router only once the row's fate is durable: after a
|
||||||
|
successful commit when the row was just deleted, or to undo a
|
||||||
|
`save_reference_image` whose row failed to commit (see both call sites).
|
||||||
|
`missing_ok` because "already gone" must never be an error here.
|
||||||
|
"""
|
||||||
|
_reference_file_path(reference).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def file_still_referenced(db: AsyncSession, path: str) -> bool:
|
||||||
|
"""True if some VisionReferenceImage row still points at *path*.
|
||||||
|
|
||||||
|
Copy-on-write (#38.6) can leave several rows sharing the same file on
|
||||||
|
disk (the copy carries over the source's `path` rather than duplicating
|
||||||
|
the file). The router must call this, after the row being deleted has
|
||||||
|
been committed, before it calls `remove_reference_file` - otherwise
|
||||||
|
deleting one task's reference would take the file out from under every
|
||||||
|
other task that still references it.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(VisionReferenceImage.id).where(VisionReferenceImage.path == path).limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def save_reference_image(
|
||||||
|
db: AsyncSession,
|
||||||
|
task_id: int,
|
||||||
|
image_bytes: bytes,
|
||||||
|
*,
|
||||||
|
note: str | None = None,
|
||||||
|
station_id: int | None = None,
|
||||||
|
device_code: str | None = None,
|
||||||
|
calibration_snapshot: dict | None = None,
|
||||||
|
extension: str = "png",
|
||||||
|
) -> VisionReferenceImage:
|
||||||
|
"""Keep an image so the graph can be composed and re-tried without a camera.
|
||||||
|
|
||||||
|
`extension` must match the upload's real content type (the router derives
|
||||||
|
it from `image.content_type`) - writing a jpeg to disk as `<uuid>.png`
|
||||||
|
would be a lie the filename tells forever.
|
||||||
|
|
||||||
|
The file is written only after the row has flushed successfully - a bad
|
||||||
|
FK or constraint fails at `flush()`, before anything exists on disk, so
|
||||||
|
there is nothing to clean up. The caller still has to remove the file if
|
||||||
|
the *commit* that follows fails (`remove_reference_file`, from the
|
||||||
|
router): a flush is not durable, and an orphaned file from a rolled-back
|
||||||
|
row is exactly what #38 found.
|
||||||
|
"""
|
||||||
|
# `upload_path` is the resolved Path, `upload_dir` the bare setting: the
|
||||||
|
# rest of the codebase uses the former (see production_export_service).
|
||||||
|
name = f"{uuid4().hex}.{extension}"
|
||||||
|
relative_path = str(Path("vision") / "reference" / str(task_id) / name)
|
||||||
|
|
||||||
|
reference = VisionReferenceImage(
|
||||||
|
task_id=task_id,
|
||||||
|
path=relative_path,
|
||||||
|
note=note,
|
||||||
|
station_id=station_id,
|
||||||
|
device_code=device_code,
|
||||||
|
calibration_snapshot=calibration_snapshot,
|
||||||
|
)
|
||||||
|
db.add(reference)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
folder = _reference_file_path(reference).parent
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
_reference_file_path(reference).write_bytes(image_bytes)
|
||||||
|
|
||||||
|
await db.refresh(reference)
|
||||||
|
return reference
|
||||||
|
|
||||||
|
|
||||||
|
async def list_reference_images(
|
||||||
|
db: AsyncSession, task_id: int,
|
||||||
|
) -> list[VisionReferenceImage]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(VisionReferenceImage)
|
||||||
|
.where(VisionReferenceImage.task_id == task_id)
|
||||||
|
.order_by(VisionReferenceImage.acquired_at)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_reference_image(
|
||||||
|
db: AsyncSession, reference_id: int,
|
||||||
|
) -> VisionReferenceImage:
|
||||||
|
"""Flush the row's deletion. The caller (router) removes the file only
|
||||||
|
once the commit that makes the deletion durable has actually succeeded -
|
||||||
|
deleting it any earlier would leave the row pointing at a file that is
|
||||||
|
already gone if the commit then failed.
|
||||||
|
"""
|
||||||
|
reference = (await db.execute(
|
||||||
|
select(VisionReferenceImage).where(VisionReferenceImage.id == reference_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if reference is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Reference image not found",
|
||||||
|
)
|
||||||
|
await db.delete(reference)
|
||||||
|
await db.flush()
|
||||||
|
return reference
|
||||||
|
|
||||||
|
|
||||||
|
async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dict:
|
||||||
|
"""Run the graph and save nothing. Composing is not measuring."""
|
||||||
|
if not task.vision_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="this task carries no vision graph",
|
||||||
|
)
|
||||||
|
return await _call_worker(
|
||||||
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
|
)
|
||||||
@@ -40,7 +40,7 @@ from src.backend.models.orm.measurement import Measurement
|
|||||||
from src.backend.models.orm.access_log import AccessLog
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from src.backend.services.auth_service import hash_password, generate_api_key
|
from src.backend.services.auth_service import hash_password, issue_api_key
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# In-memory SQLite engine for tests
|
# In-memory SQLite engine for tests
|
||||||
@@ -142,7 +142,6 @@ async def _create_user(
|
|||||||
active: bool = True,
|
active: bool = True,
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Insert a user into the test DB and return it with an API key set."""
|
"""Insert a user into the test DB and return it with an API key set."""
|
||||||
api_key = generate_api_key()
|
|
||||||
user = User(
|
user = User(
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=hash_password(password),
|
password_hash=hash_password(password),
|
||||||
@@ -150,13 +149,14 @@ async def _create_user(
|
|||||||
roles=roles or [],
|
roles=roles or [],
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
active=active,
|
active=active,
|
||||||
api_key=api_key,
|
|
||||||
language_pref="en",
|
language_pref="en",
|
||||||
theme_pref="light",
|
theme_pref="light",
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
|
# Clear key of this test session; the DB only keeps its hash.
|
||||||
|
user.plain_key = await issue_api_key(session, user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -278,4 +278,4 @@ async def create_test_recipe(
|
|||||||
|
|
||||||
def auth_headers(user: User) -> dict[str, str]:
|
def auth_headers(user: User) -> dict[str, str]:
|
||||||
"""Return headers dict with the user's API key for authenticated requests."""
|
"""Return headers dict with the user's API key for authenticated requests."""
|
||||||
return {"X-API-Key": user.api_key}
|
return {"X-API-Key": user.plain_key}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Unit tests for AccessLogMiddleware (gitea#36 point 6).
|
||||||
|
|
||||||
|
- action must be truncated to fit AccessLog.action (String(100)): an
|
||||||
|
untruncated long path used to fail the INSERT silently (swallowed by the
|
||||||
|
broad except in dispatch()).
|
||||||
|
- ip_address must come from the same `_client_ip` the rate limiter uses
|
||||||
|
(proxy-aware), not the raw `request.client.host`, which behind
|
||||||
|
Nginx/Flask is always localhost.
|
||||||
|
|
||||||
|
These stub out async_session_factory and inspect the compiled INSERT
|
||||||
|
statement directly, rather than going through a real DB round trip: Core
|
||||||
|
`insert()` against the test's in-memory SQLite has an unrelated
|
||||||
|
autoincrement quirk (unrelated to this fix) that makes end-to-end
|
||||||
|
assertions on the persisted row unreliable here.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
"""Records the statement passed to execute(); no real DB involved."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.executed_stmt = None
|
||||||
|
|
||||||
|
async def execute(self, stmt):
|
||||||
|
self.executed_stmt = stmt
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(path: str, extra_headers: list[tuple[bytes, bytes]] | None = None) -> Request:
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": path,
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": extra_headers or [],
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
}
|
||||||
|
return Request(scope)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_long_path_action_is_truncated():
|
||||||
|
fake_session = _FakeSession()
|
||||||
|
long_path = "/api/files/" + "a" * 200
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.backend.api.middleware.logging.async_session_factory",
|
||||||
|
return_value=fake_session,
|
||||||
|
):
|
||||||
|
middleware = AccessLogMiddleware(app=None)
|
||||||
|
request = _make_request(long_path)
|
||||||
|
|
||||||
|
async def call_next(_req):
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
await middleware.dispatch(request, call_next)
|
||||||
|
|
||||||
|
assert fake_session.executed_stmt is not None
|
||||||
|
params = fake_session.executed_stmt.compile().params
|
||||||
|
assert len(params["action"]) <= 100
|
||||||
|
assert params["action"] == f"GET {long_path}"[:100]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_short_path_action_is_unaffected():
|
||||||
|
fake_session = _FakeSession()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.backend.api.middleware.logging.async_session_factory",
|
||||||
|
return_value=fake_session,
|
||||||
|
):
|
||||||
|
middleware = AccessLogMiddleware(app=None)
|
||||||
|
request = _make_request("/api/settings/")
|
||||||
|
|
||||||
|
async def call_next(_req):
|
||||||
|
return Response(status_code=200)
|
||||||
|
|
||||||
|
await middleware.dispatch(request, call_next)
|
||||||
|
|
||||||
|
params = fake_session.executed_stmt.compile().params
|
||||||
|
assert params["action"] == "GET /api/settings/"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ip_address_uses_client_ip_resolution():
|
||||||
|
"""trusted_proxy_count defaults to 1: the last XFF hop (our own proxy's
|
||||||
|
add) is trusted, not request.client.host (always the proxy itself)."""
|
||||||
|
fake_session = _FakeSession()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.backend.api.middleware.logging.async_session_factory",
|
||||||
|
return_value=fake_session,
|
||||||
|
):
|
||||||
|
middleware = AccessLogMiddleware(app=None)
|
||||||
|
request = _make_request(
|
||||||
|
"/api/settings/",
|
||||||
|
extra_headers=[(b"x-forwarded-for", b"203.0.113.7, 10.0.0.1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def call_next(_req):
|
||||||
|
return Response(status_code=200)
|
||||||
|
|
||||||
|
await middleware.dispatch(request, call_next)
|
||||||
|
|
||||||
|
params = fake_session.executed_stmt.compile().params
|
||||||
|
assert params["ip_address"] == "10.0.0.1"
|
||||||
|
assert params["ip_address"] != "127.0.0.1" # not request.client.host
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tests for ai_service.py error handling (gitea#36 point 7).
|
||||||
|
|
||||||
|
A malformed response from the AI model (missing fields, invalid JSON) must
|
||||||
|
raise the dedicated AIResponseError, not a bare KeyError/JSONDecodeError —
|
||||||
|
JSONDecodeError in particular is itself a ValueError subclass, so without
|
||||||
|
this it was caught by the router's `except ValueError` (meant for our own
|
||||||
|
config errors) and misreported as a 400 instead of a 502.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.backend.services import ai_service
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, json_data):
|
||||||
|
self._json_data = json_data
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._json_data
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_openrouter(monkeypatch, response_json: dict):
|
||||||
|
monkeypatch.setattr(ai_service, "extract_text_from_pdf", lambda _b: "some extracted text")
|
||||||
|
monkeypatch.setattr(ai_service.settings, "openrouter_api_key", "test-key")
|
||||||
|
monkeypatch.setattr(ai_service.settings, "openrouter_model", "test-model")
|
||||||
|
|
||||||
|
fake_client = AsyncMock()
|
||||||
|
fake_client.post = AsyncMock(return_value=_FakeResponse(response_json))
|
||||||
|
fake_client.__aenter__ = AsyncMock(return_value=fake_client)
|
||||||
|
fake_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
return patch("src.backend.services.ai_service.httpx.AsyncClient", return_value=fake_client)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_choices_key_raises_ai_response_error(monkeypatch):
|
||||||
|
with _patch_openrouter(monkeypatch, {"no_choices_here": True}):
|
||||||
|
with pytest.raises(ai_service.AIResponseError):
|
||||||
|
await ai_service.parse_technical_sheet(b"%PDF-fake")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_json_content_raises_ai_response_error(monkeypatch):
|
||||||
|
bad_content = "not valid json {{{"
|
||||||
|
response_json = {"choices": [{"message": {"content": bad_content}}]}
|
||||||
|
with _patch_openrouter(monkeypatch, response_json):
|
||||||
|
with pytest.raises(ai_service.AIResponseError):
|
||||||
|
await ai_service.parse_technical_sheet(b"%PDF-fake")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_valid_json_content_returns_tasks(monkeypatch):
|
||||||
|
tasks = [{"title": "MATERIALI", "directive": "Verificare", "description": "..."}]
|
||||||
|
response_json = {"choices": [{"message": {"content": json.dumps(tasks)}}]}
|
||||||
|
with _patch_openrouter(monkeypatch, response_json):
|
||||||
|
result = await ai_service.parse_technical_sheet(b"%PDF-fake")
|
||||||
|
assert result == tasks
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_extracted_text_returns_empty_list(monkeypatch):
|
||||||
|
"""No AI call at all when the PDF has no extractable text."""
|
||||||
|
monkeypatch.setattr(ai_service, "extract_text_from_pdf", lambda _b: " ")
|
||||||
|
result = await ai_service.parse_technical_sheet(b"%PDF-fake")
|
||||||
|
assert result == []
|
||||||
@@ -78,6 +78,136 @@ class TestLogin:
|
|||||||
assert len(api_key) >= 32
|
assert len(api_key) >= 32
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginKeyPerSession:
|
||||||
|
"""Each login issues its own key; none revokes another (#25, #41)."""
|
||||||
|
|
||||||
|
async def test_two_logins_same_user_get_distinct_valid_keys(
|
||||||
|
self, client: AsyncClient, db_session
|
||||||
|
):
|
||||||
|
await _create_user(db_session, username="twostations", password="TwoStations1")
|
||||||
|
creds = {"username": "twostations", "password": "TwoStations1"}
|
||||||
|
resp1 = await client.post("/api/auth/login", json=creds)
|
||||||
|
resp2 = await client.post("/api/auth/login", json=creds)
|
||||||
|
k1, k2 = resp1.json()["api_key"], resp2.json()["api_key"]
|
||||||
|
assert k1 != k2
|
||||||
|
for k in (k1, k2):
|
||||||
|
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
async def test_db_stores_hash_never_clear_key(
|
||||||
|
self, client: AsyncClient, db_session
|
||||||
|
):
|
||||||
|
from sqlalchemy import select
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.services.auth_service import hash_api_key
|
||||||
|
|
||||||
|
await _create_user(db_session, username="hashed", password="HashedPass1")
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login", json={"username": "hashed", "password": "HashedPass1"}
|
||||||
|
)
|
||||||
|
key = resp.json()["api_key"]
|
||||||
|
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
|
||||||
|
assert hash_api_key(key) in hashes
|
||||||
|
assert key not in hashes
|
||||||
|
assert all(len(h) == 64 for h in hashes)
|
||||||
|
|
||||||
|
async def test_expired_key_gives_401(self, client: AsyncClient, db_session):
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import update
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.services.auth_service import utcnow
|
||||||
|
|
||||||
|
user = await _create_user(db_session, username="expired", password="ExpiredPass1")
|
||||||
|
await db_session.execute(
|
||||||
|
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
|
||||||
|
)
|
||||||
|
resp = await client.get("/api/auth/me", headers=auth_headers(user))
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "expired" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
async def test_use_renews_expiry_only_after_interval(
|
||||||
|
self, client: AsyncClient, db_session
|
||||||
|
):
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.services.auth_service import utcnow
|
||||||
|
|
||||||
|
user = await _create_user(db_session, username="renew", password="RenewPass1")
|
||||||
|
headers = auth_headers(user)
|
||||||
|
old = utcnow() - timedelta(hours=10)
|
||||||
|
|
||||||
|
# Recent use: no write, expiry untouched.
|
||||||
|
recent = utcnow() - timedelta(minutes=1)
|
||||||
|
await db_session.execute(
|
||||||
|
update(ApiKey).values(expires_at=old + timedelta(hours=24), last_used_at=recent)
|
||||||
|
)
|
||||||
|
await client.get("/api/auth/me", headers=headers)
|
||||||
|
row = (await db_session.execute(select(ApiKey))).scalar_one()
|
||||||
|
await db_session.refresh(row)
|
||||||
|
assert row.last_used_at == recent
|
||||||
|
|
||||||
|
# Stale last use: expiry is pushed forward.
|
||||||
|
await db_session.execute(
|
||||||
|
update(ApiKey).values(last_used_at=utcnow() - timedelta(minutes=10))
|
||||||
|
)
|
||||||
|
before = utcnow()
|
||||||
|
resp = await client.get("/api/auth/me", headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
await db_session.refresh(row)
|
||||||
|
assert row.expires_at >= before + timedelta(hours=23, minutes=59)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogout:
|
||||||
|
"""Logout deletes only this session's key (#41)."""
|
||||||
|
|
||||||
|
async def test_logout_removes_only_own_key(self, client: AsyncClient, db_session):
|
||||||
|
await _create_user(db_session, username="shared_logout", password="SharedPass1")
|
||||||
|
creds = {"username": "shared_logout", "password": "SharedPass1"}
|
||||||
|
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
|
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
|
resp = await client.post("/api/auth/logout", headers={"X-API-Key": k1})
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
assert (await client.get("/api/auth/me", headers={"X-API-Key": k1})).status_code == 401
|
||||||
|
assert (await client.get("/api/auth/me", headers={"X-API-Key": k2})).status_code == 200
|
||||||
|
|
||||||
|
async def test_logout_all_sessions_removes_every_key(
|
||||||
|
self, client: AsyncClient, db_session
|
||||||
|
):
|
||||||
|
await _create_user(db_session, username="shared_logout_all", password="SharedPass1")
|
||||||
|
creds = {"username": "shared_logout_all", "password": "SharedPass1"}
|
||||||
|
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
|
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/logout", params={"all_sessions": "true"},
|
||||||
|
headers={"X-API-Key": k1},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
for k in (k1, k2):
|
||||||
|
assert (await client.get("/api/auth/me", headers={"X-API-Key": k})).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthenticateUserTiming:
|
||||||
|
"""authenticate_user pays the bcrypt cost even for an unknown username (#25)."""
|
||||||
|
|
||||||
|
async def test_unknown_username_still_returns_none(self, db_session):
|
||||||
|
from src.backend.services.auth_service import authenticate_user
|
||||||
|
|
||||||
|
result = await authenticate_user(db_session, "no-such-user-at-all", "whatever")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_dummy_hash_is_a_valid_bcrypt_hash(self):
|
||||||
|
from src.backend.services.auth_service import (
|
||||||
|
_DUMMY_PASSWORD_HASH,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert verify_password(
|
||||||
|
"tiemeasureflow-dummy-hash-for-timing", _DUMMY_PASSWORD_HASH
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestHealthAndAuth:
|
class TestHealthAndAuth:
|
||||||
"""Health check and auth requirement tests."""
|
"""Health check and auth requirement tests."""
|
||||||
|
|
||||||
@@ -94,3 +224,69 @@ class TestHealthAndAuth:
|
|||||||
resp = await client.get("/api/users")
|
resp = await client.get("/api/users")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
|
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRevokeAllKeys:
|
||||||
|
"""Password change, deactivation and regenerate invalidate every key (#41)."""
|
||||||
|
|
||||||
|
async def _two_keys(self, client, db_session, name):
|
||||||
|
user = await _create_user(db_session, username=name, password="KeysPass123")
|
||||||
|
creds = {"username": name, "password": "KeysPass123"}
|
||||||
|
keys = [
|
||||||
|
(await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
|
for _ in range(2)
|
||||||
|
]
|
||||||
|
return user, keys
|
||||||
|
|
||||||
|
async def _all_401(self, client, keys):
|
||||||
|
for k in keys:
|
||||||
|
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
async def test_password_change_invalidates_all(self, client, db_session, admin_user):
|
||||||
|
user, keys = await self._two_keys(client, db_session, "pwkeys")
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/users/{user.id}/password",
|
||||||
|
headers=auth_headers(admin_user), json={"password": "NewSecurePass1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
await self._all_401(client, keys)
|
||||||
|
|
||||||
|
async def test_deactivation_invalidates_all(self, client, db_session, admin_user):
|
||||||
|
user, keys = await self._two_keys(client, db_session, "offkeys")
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/api/users/{user.id}", headers=auth_headers(admin_user)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
await self._all_401(client, keys)
|
||||||
|
|
||||||
|
async def test_regenerate_invalidates_old_keys(self, client, db_session, admin_user):
|
||||||
|
user, keys = await self._two_keys(client, db_session, "regenkeys")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/users/{user.id}/regenerate-key", headers=auth_headers(admin_user)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
await self._all_401(client, keys)
|
||||||
|
new = resp.json()["api_key"]
|
||||||
|
assert (await client.get("/api/auth/me", headers={"X-API-Key": new})).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpiredKeyCleanup:
|
||||||
|
async def test_expired_key_removed_at_next_login(self, client, db_session):
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.services.auth_service import hash_api_key, utcnow
|
||||||
|
|
||||||
|
user = await _create_user(db_session, username="cleanup", password="CleanPass123")
|
||||||
|
old_hash = hash_api_key(user.plain_key)
|
||||||
|
await db_session.execute(
|
||||||
|
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login", json={"username": "cleanup", "password": "CleanPass123"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
|
||||||
|
assert old_hash not in hashes
|
||||||
|
assert len(hashes) == 1
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Tests for Settings' production-secret guard (issue #33, backend part)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.backend.config import Settings, _DEFAULT_DB_PASSWORD, _DEFAULT_SECRET_KEY
|
||||||
|
|
||||||
|
_REAL_SECRET_KEY = "a-real-generated-secret-key-not-the-default"
|
||||||
|
_REAL_DB_PASSWORD = "a-real-generated-db-password-not-the-default"
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides) -> Settings:
|
||||||
|
"""Build a Settings instance without reading the real .env file or
|
||||||
|
inheriting stray SERVER_SECRET_KEY/DB_PASSWORD from the shell env."""
|
||||||
|
overrides.setdefault("server_secret_key", _DEFAULT_SECRET_KEY)
|
||||||
|
overrides.setdefault("db_password", _DEFAULT_DB_PASSWORD)
|
||||||
|
return Settings(_env_file=None, **overrides)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductionSecretGuard:
|
||||||
|
def test_dev_env_allows_default_secrets(self):
|
||||||
|
s = _settings(app_env="dev")
|
||||||
|
assert s.server_secret_key == _DEFAULT_SECRET_KEY
|
||||||
|
assert s.db_password == _DEFAULT_DB_PASSWORD
|
||||||
|
|
||||||
|
def test_test_env_allows_default_secrets(self):
|
||||||
|
s = _settings(app_env="test")
|
||||||
|
assert s.server_secret_key == _DEFAULT_SECRET_KEY
|
||||||
|
|
||||||
|
def test_production_env_rejects_default_secret_key(self):
|
||||||
|
with pytest.raises(RuntimeError, match="SERVER_SECRET_KEY"):
|
||||||
|
_settings(app_env="production", db_password=_REAL_DB_PASSWORD)
|
||||||
|
|
||||||
|
def test_production_env_rejects_default_db_password(self):
|
||||||
|
with pytest.raises(RuntimeError, match="DB_PASSWORD"):
|
||||||
|
_settings(app_env="production", server_secret_key=_REAL_SECRET_KEY)
|
||||||
|
|
||||||
|
def test_production_env_accepts_real_secrets(self):
|
||||||
|
s = _settings(
|
||||||
|
app_env="production",
|
||||||
|
server_secret_key=_REAL_SECRET_KEY,
|
||||||
|
db_password=_REAL_DB_PASSWORD,
|
||||||
|
)
|
||||||
|
assert s.app_env == "production"
|
||||||
|
|
||||||
|
def test_module_level_settings_instance_starts_without_env(self):
|
||||||
|
"""The already-instantiated `settings` (imported at process start)
|
||||||
|
must not have raised - this is what "tests/dev start without .env"
|
||||||
|
requires: APP_ENV defaults to "dev", so the guard never fires here."""
|
||||||
|
from src.backend.config import settings
|
||||||
|
|
||||||
|
assert settings.app_env == "dev"
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""#40: after a reload, the quotes already measured in the cycle come back.
|
||||||
|
|
||||||
|
The cycle has no column of its own: it opens at the latest start, cycle_completed
|
||||||
|
or remeasure-of-this-task event of the run, and a task_measured of the task after
|
||||||
|
that boundary means the task is closed in this cycle.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
T0 = datetime(2026, 10, 1, 8, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup(db, user):
|
||||||
|
recipe = await create_test_recipe(db, user.id)
|
||||||
|
version = (await db.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
first = (await db.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
second = RecipeSubtask(
|
||||||
|
task_id=task.id, marker_number=2, description="Quota 2",
|
||||||
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||||
|
)
|
||||||
|
db.add(second)
|
||||||
|
station = Station(code="ST-CYC", name="St", active=True, created_by=user.id)
|
||||||
|
db.add(station)
|
||||||
|
await db.commit()
|
||||||
|
run = ProductionRun(
|
||||||
|
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||||
|
operator_id=user.id, status="running", active_station_id=station.id,
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.commit()
|
||||||
|
return run, version, task, first, second
|
||||||
|
|
||||||
|
|
||||||
|
async def _event(db, run, user, kind, minutes, task_id=None):
|
||||||
|
db.add(ProductionEvent(
|
||||||
|
run_id=run.id, event_type=kind, user_id=user.id, task_id=task_id,
|
||||||
|
created_at=T0 + timedelta(minutes=minutes),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _meas(db, run, version, subtask, user, minutes, value=10.0):
|
||||||
|
db.add(Measurement(
|
||||||
|
subtask_id=subtask.id, version_id=version.id, measured_by=user.id,
|
||||||
|
value=value, pass_fail="pass", production_run_id=run.id,
|
||||||
|
measured_at=T0 + timedelta(minutes=minutes),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get(client, user, run, task):
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/measurements/current-cycle", headers=auth_headers(user),
|
||||||
|
params={"production_run_id": run.id, "task_id": task.id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.json()["measurements"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_only_second_cycle_after_cycle_completed(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user,
|
||||||
|
):
|
||||||
|
u = measurement_tec_user
|
||||||
|
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||||
|
await _event(db_session, run, u, "start", 0)
|
||||||
|
await _meas(db_session, run, version, q1, u, 1, 10.1)
|
||||||
|
await _meas(db_session, run, version, q2, u, 2, 10.2)
|
||||||
|
await _event(db_session, run, u, "cycle_completed", 3)
|
||||||
|
await _meas(db_session, run, version, q1, u, 4, 10.3)
|
||||||
|
|
||||||
|
rows = await _get(client, u, run, task)
|
||||||
|
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.3)]
|
||||||
|
assert rows[0]["pass_fail"] == "pass"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_last_reading_of_a_quote_wins(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user,
|
||||||
|
):
|
||||||
|
u = measurement_tec_user
|
||||||
|
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||||
|
await _event(db_session, run, u, "start", 0)
|
||||||
|
await _meas(db_session, run, version, q1, u, 1, 10.1)
|
||||||
|
await _meas(db_session, run, version, q1, u, 2, 10.4)
|
||||||
|
|
||||||
|
rows = await _get(client, u, run, task)
|
||||||
|
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.4)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remeasure_of_the_task_empties_the_list(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user,
|
||||||
|
):
|
||||||
|
u = measurement_tec_user
|
||||||
|
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||||
|
await _event(db_session, run, u, "start", 0)
|
||||||
|
await _meas(db_session, run, version, q1, u, 1)
|
||||||
|
await _event(db_session, run, u, "remeasure", 2, task_id=task.id)
|
||||||
|
|
||||||
|
assert await _get(client, u, run, task) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remeasure_of_another_task_does_not_move_the_boundary(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user,
|
||||||
|
):
|
||||||
|
u = measurement_tec_user
|
||||||
|
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||||
|
other = RecipeTask(
|
||||||
|
version_id=version.id, title="Altro", task_type=task.task_type, order_index=9,
|
||||||
|
)
|
||||||
|
db_session.add(other)
|
||||||
|
await db_session.commit()
|
||||||
|
await _event(db_session, run, u, "start", 0)
|
||||||
|
await _meas(db_session, run, version, q1, u, 1)
|
||||||
|
await _event(db_session, run, u, "remeasure", 2, task_id=other.id)
|
||||||
|
|
||||||
|
assert len(await _get(client, u, run, task)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_task_measured_after_boundary_means_closed(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user,
|
||||||
|
):
|
||||||
|
u = measurement_tec_user
|
||||||
|
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||||
|
await _event(db_session, run, u, "start", 0)
|
||||||
|
await _meas(db_session, run, version, q1, u, 1)
|
||||||
|
await _meas(db_session, run, version, q2, u, 2)
|
||||||
|
await _event(db_session, run, u, "task_measured", 3, task_id=task.id)
|
||||||
|
|
||||||
|
assert await _get(client, u, run, task) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_task_measured_before_boundary_does_not_close_next_cycle(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user,
|
||||||
|
):
|
||||||
|
u = measurement_tec_user
|
||||||
|
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||||
|
await _event(db_session, run, u, "start", 0)
|
||||||
|
await _event(db_session, run, u, "task_measured", 1, task_id=task.id)
|
||||||
|
await _event(db_session, run, u, "cycle_completed", 2)
|
||||||
|
await _meas(db_session, run, version, q1, u, 3)
|
||||||
|
|
||||||
|
assert len(await _get(client, u, run, task)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_permissions(client: AsyncClient, db_session, measurement_tec_user, maker_user):
|
||||||
|
run, _v, task, _q1, _q2 = await _setup(db_session, measurement_tec_user)
|
||||||
|
params = {"production_run_id": run.id, "task_id": task.id}
|
||||||
|
|
||||||
|
no_key = await client.get("/api/measurements/current-cycle", params=params)
|
||||||
|
assert no_key.status_code in (401, 403)
|
||||||
|
|
||||||
|
forbidden = await client.get(
|
||||||
|
"/api/measurements/current-cycle", headers=auth_headers(maker_user), params=params,
|
||||||
|
)
|
||||||
|
assert forbidden.status_code == 403
|
||||||
@@ -7,9 +7,13 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.tests.conftest import auth_headers
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
class TestUploadFile:
|
class TestUploadFile:
|
||||||
@@ -61,6 +65,51 @@ class TestUploadFile:
|
|||||||
assert data["file_type"] == "image/jpeg"
|
assert data["file_type"] == "image/jpeg"
|
||||||
assert data["file_size"] > 0
|
assert data["file_size"] > 0
|
||||||
|
|
||||||
|
async def test_extension_forced_to_detected_type_ignores_filename(
|
||||||
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""gitea#36 point 2: the stored extension follows the detected magic
|
||||||
|
bytes, not the client-supplied filename — even a misleading one."""
|
||||||
|
jpeg_bytes = b"\xff\xd8\xff\xe0\x00\x10JFIF" + b"\x00" * 20 + b"\xff\xd9"
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
mock_settings.max_upload_size_mb = 50
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/files/upload",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
# Filename claims .txt; content is a real JPEG.
|
||||||
|
files={"file": ("photo.txt", io.BytesIO(jpeg_bytes), "text/plain")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["file_type"] == "image/jpeg"
|
||||||
|
assert data["file_path"].endswith(".jpg")
|
||||||
|
|
||||||
|
async def test_upload_declared_type_lies_about_html_content(
|
||||||
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""gitea#36 point 2: a file whose bytes are HTML must be rejected even
|
||||||
|
when the client declares Content-Type: image/png and names it .png —
|
||||||
|
this is what previously let a stored file be served back as
|
||||||
|
text/html by extension and executed by the browser."""
|
||||||
|
html_bytes = b"<html><body><script>alert(1)</script></body></html>"
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
mock_settings.max_upload_size_mb = 50
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/files/upload",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"file": ("x.png", io.BytesIO(html_bytes), "image/png")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert not any(tmp_path.rglob("x.png"))
|
||||||
|
|
||||||
async def test_upload_invalid_type(
|
async def test_upload_invalid_type(
|
||||||
self, client: AsyncClient, maker_user: User
|
self, client: AsyncClient, maker_user: User
|
||||||
):
|
):
|
||||||
@@ -134,6 +183,79 @@ class TestGetFile:
|
|||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRestrictedFile:
|
||||||
|
"""gitea#21: GET statistics/* and reports/* need Metrologist/Supervisor/admin."""
|
||||||
|
|
||||||
|
async def test_measurement_tec_forbidden_on_statistics(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
stats_dir = tmp_path / "statistics"
|
||||||
|
stats_dir.mkdir()
|
||||||
|
(stats_dir / "production_1_x.csv").write_text("a,b\n1,2\n")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/files/statistics/production_1_x.csv",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
async def test_metrologist_allowed_on_statistics(
|
||||||
|
self, client: AsyncClient, metrologist_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
stats_dir = tmp_path / "statistics"
|
||||||
|
stats_dir.mkdir()
|
||||||
|
(stats_dir / "production_1_x.csv").write_text("a,b\n1,2\n")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/files/statistics/production_1_x.csv",
|
||||||
|
headers=auth_headers(metrologist_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
async def test_admin_allowed_on_reports(
|
||||||
|
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
reports_dir = tmp_path / "reports"
|
||||||
|
reports_dir.mkdir()
|
||||||
|
(reports_dir / "spc_1.pdf").write_bytes(b"%PDF-1.4")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/files/reports/spc_1.pdf",
|
||||||
|
headers=auth_headers(admin_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
async def test_maker_unrestricted_path_still_allowed(
|
||||||
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""The restriction is scoped to statistics/reports, not every file."""
|
||||||
|
test_file = tmp_path / "1" / "1" / "drawing.png"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/files/1/1/drawing.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteFile:
|
class TestDeleteFile:
|
||||||
"""DELETE /api/files/{file_path} tests."""
|
"""DELETE /api/files/{file_path} tests."""
|
||||||
|
|
||||||
@@ -155,6 +277,242 @@ class TestDeleteFile:
|
|||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
assert not test_file.exists()
|
assert not test_file.exists()
|
||||||
|
|
||||||
|
async def test_delete_unreferenced_recipe_image_allowed(
|
||||||
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""A file under a recipe/version path but referenced nowhere is safe."""
|
||||||
|
test_file = tmp_path / "5" / "12" / "images" / "orphan.png"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/5/12/images/orphan.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert not test_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteProtectedFolders:
|
||||||
|
"""gitea#21: statistics/, reports/, logos/ can never be deleted here."""
|
||||||
|
|
||||||
|
async def test_maker_delete_statistics_forbidden(
|
||||||
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
stats_dir = tmp_path / "statistics"
|
||||||
|
stats_dir.mkdir()
|
||||||
|
test_file = stats_dir / "production_1_x.csv"
|
||||||
|
test_file.write_text("a,b\n1,2\n")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/statistics/production_1_x.csv",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
async def test_admin_delete_reports_forbidden(
|
||||||
|
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""Even an admin cannot delete through this route: not just a role gate."""
|
||||||
|
reports_dir = tmp_path / "reports"
|
||||||
|
reports_dir.mkdir()
|
||||||
|
test_file = reports_dir / "spc_1.pdf"
|
||||||
|
test_file.write_bytes(b"%PDF-1.4")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/reports/spc_1.pdf",
|
||||||
|
headers=auth_headers(admin_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
async def test_delete_logo_forbidden(
|
||||||
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""The logo is only ever replaced via POST /api/settings/logo."""
|
||||||
|
logos_dir = tmp_path / "logos"
|
||||||
|
logos_dir.mkdir()
|
||||||
|
test_file = logos_dir / "company_logo.png"
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/logos/company_logo.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteReferencedRecipeFile:
|
||||||
|
"""gitea#21: a recipe file backing the current version, a historical
|
||||||
|
version, or measured data must survive a DELETE (409)."""
|
||||||
|
|
||||||
|
async def test_current_version_no_measurements_is_allowed(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
maker_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
tmp_path: Path,
|
||||||
|
):
|
||||||
|
"""Still a draft (current version, nothing measured against it yet):
|
||||||
|
swapping the drawing out is exactly what editing a recipe does."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-A")
|
||||||
|
version = recipe.versions[0]
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalar_one()
|
||||||
|
task.file_path = "recipe-a/1/drawing.png"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
test_file = tmp_path / "recipe-a" / "1" / "drawing.png"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/recipe-a/1/drawing.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert not test_file.exists()
|
||||||
|
|
||||||
|
async def test_current_version_with_measurements_is_409(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
maker_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
tmp_path: Path,
|
||||||
|
):
|
||||||
|
"""Once something has been measured against this version, its drawing
|
||||||
|
must stay: the audit trail needs it to make sense of the numbers."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-D")
|
||||||
|
version = recipe.versions[0]
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalar_one()
|
||||||
|
task.file_path = "recipe-d/1/drawing.png"
|
||||||
|
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
|
||||||
|
subtask = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalar_one()
|
||||||
|
db_session.add(Measurement(
|
||||||
|
subtask_id=subtask.id,
|
||||||
|
version_id=version.id,
|
||||||
|
measured_by=maker_user.id,
|
||||||
|
value=10.0,
|
||||||
|
pass_fail="pass",
|
||||||
|
input_method="manual",
|
||||||
|
))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
test_file = tmp_path / "recipe-d" / "1" / "drawing.png"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/recipe-d/1/drawing.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
async def test_noncurrent_version_task_file_is_409(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
maker_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
tmp_path: Path,
|
||||||
|
):
|
||||||
|
"""A file kept only for a superseded version is history, not garbage."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-B")
|
||||||
|
|
||||||
|
old_version = RecipeVersion(
|
||||||
|
recipe_id=recipe.id,
|
||||||
|
version_number=2,
|
||||||
|
is_current=False,
|
||||||
|
created_by=maker_user.id,
|
||||||
|
change_notes="Superseded",
|
||||||
|
)
|
||||||
|
db_session.add(old_version)
|
||||||
|
await db_session.flush()
|
||||||
|
old_task = RecipeTask(
|
||||||
|
version_id=old_version.id,
|
||||||
|
order_index=0,
|
||||||
|
title="Old drawing",
|
||||||
|
task_type="drawing",
|
||||||
|
file_path="recipe-b/2/old-drawing.png",
|
||||||
|
)
|
||||||
|
db_session.add(old_task)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
test_file = tmp_path / "recipe-b" / "2" / "old-drawing.png"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/recipe-b/2/old-drawing.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
async def test_recipe_cover_image_is_409(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
maker_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
tmp_path: Path,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-C")
|
||||||
|
recipe.image_path = "recipe-c/cover.png"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
test_file = tmp_path / "recipe-c" / "cover.png"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/recipe-c/cover.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
|
||||||
class TestFilenameSanitization:
|
class TestFilenameSanitization:
|
||||||
"""Path traversal protection tests."""
|
"""Path traversal protection tests."""
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"""Tests for measurements router (/api/measurements)."""
|
"""Tests for measurements router (/api/measurements)."""
|
||||||
|
import csv
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
from src.backend.services import auth_service
|
||||||
|
from src.backend.tests.conftest import _create_user, auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
async def _get_subtask_and_version(
|
async def _get_subtask_and_version(
|
||||||
@@ -223,6 +227,281 @@ class TestMeasurementPassFail:
|
|||||||
assert resp.json()["pass_fail"] == "fail"
|
assert resp.json()["pass_fail"] == "fail"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMeasurementValueBounds:
|
||||||
|
"""value rejects NaN/Infinity and out-of-DECIMAL(12,6)-scale numbers (#20)."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_value", [1e300, 1_000_000])
|
||||||
|
async def test_out_of_scale_value_is_422(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
measurement_tec_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
bad_value: float,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": bad_value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw_token", ["NaN", "Infinity", "-Infinity"])
|
||||||
|
async def test_nan_and_infinity_are_422(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
measurement_tec_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
raw_token: str,
|
||||||
|
):
|
||||||
|
"""NaN/Infinity are not valid JSON: httpx's own encoder refuses to send
|
||||||
|
them via ``json=``, so the raw body is built by hand here - exactly
|
||||||
|
what a client that is not httpx could still put on the wire."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
body = (
|
||||||
|
'{"subtask_id": %d, "version_id": %d, "value": %s}'
|
||||||
|
% (subtask_id, version_id, raw_token)
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers={**auth_headers(measurement_tec_user), "Content-Type": "application/json"},
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_value_is_rounded_before_the_verdict(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
measurement_tec_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""10.5000004 rounds to 10.5 (=utl) first, so it is not a fail."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": 10.5000004,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["value"] == pytest.approx(10.5)
|
||||||
|
# 10.5 is > uwl(10.3) but not > utl(10.5): warning, not fail.
|
||||||
|
assert data["pass_fail"] == "warning"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveMeasurementCoherence:
|
||||||
|
"""save_measurement checks quote/version/run coherence before insert (#5)."""
|
||||||
|
|
||||||
|
async def _station(self, db_session, user_id, code):
|
||||||
|
station = Station(
|
||||||
|
code=code, name=f"Stazione {code}", active=True, created_by=user_id,
|
||||||
|
)
|
||||||
|
db_session.add(station)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(station)
|
||||||
|
return station
|
||||||
|
|
||||||
|
async def _supervisor(self, db_session, username):
|
||||||
|
user = await auth_service.create_user(
|
||||||
|
db_session, username=username, password="TurnoPwd1",
|
||||||
|
display_name="Capo Turno", roles=["Supervisor"],
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
return user, "TurnoPwd1"
|
||||||
|
|
||||||
|
async def test_quota_of_another_recipe_version_is_422(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe_a = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-A")
|
||||||
|
recipe_b = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-B")
|
||||||
|
subtask_a, _version_a = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe_a.id
|
||||||
|
)
|
||||||
|
_subtask_b, version_b = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe_b.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"subtask_id": subtask_a, "version_id": version_b, "value": 10.0},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_measurement_of_other_recipe_in_run_is_422(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe_a = await create_test_recipe(
|
||||||
|
db_session, measurement_tec_user.id, code="COH-RUNA"
|
||||||
|
)
|
||||||
|
recipe_b = await create_test_recipe(
|
||||||
|
db_session, measurement_tec_user.id, code="COH-RUNB"
|
||||||
|
)
|
||||||
|
station = await self._station(db_session, admin_user.id, "ST-COH1")
|
||||||
|
|
||||||
|
run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe_a.id},
|
||||||
|
)).json()
|
||||||
|
|
||||||
|
subtask_b, version_b = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe_b.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_b,
|
||||||
|
"version_id": version_b,
|
||||||
|
"value": 10.0,
|
||||||
|
"production_run_id": run["id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_measurement_on_closed_run_is_409(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-CLOSED")
|
||||||
|
station = await self._station(db_session, admin_user.id, "ST-COH2")
|
||||||
|
run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||||
|
)).json()
|
||||||
|
supervisor, password = await self._supervisor(db_session, "capo-coh-close")
|
||||||
|
await client.post(
|
||||||
|
f"/api/production-runs/{run['id']}/close",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"supervisor_username": supervisor.username,
|
||||||
|
"supervisor_password": password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": 10.0,
|
||||||
|
"production_run_id": run["id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
async def test_measurement_on_paused_run_is_409(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-PAUSED")
|
||||||
|
station = await self._station(db_session, admin_user.id, "ST-COH3")
|
||||||
|
run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||||
|
)).json()
|
||||||
|
supervisor, password = await self._supervisor(db_session, "capo-coh-pause")
|
||||||
|
await client.post(
|
||||||
|
f"/api/production-runs/{run['id']}/pause",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"supervisor_username": supervisor.username,
|
||||||
|
"supervisor_password": password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": 10.0,
|
||||||
|
"production_run_id": run["id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
async def test_valid_measurement_in_matching_run_is_200(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-OK")
|
||||||
|
station = await self._station(db_session, admin_user.id, "ST-COH4")
|
||||||
|
run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||||
|
)).json()
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": 10.0,
|
||||||
|
"production_run_id": run["id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
async def test_measurement_on_unknown_run_is_404(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-404")
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": 10.0,
|
||||||
|
"production_run_id": 999999,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestListMeasurements:
|
class TestListMeasurements:
|
||||||
"""GET /api/measurements/ tests."""
|
"""GET /api/measurements/ tests."""
|
||||||
|
|
||||||
@@ -366,3 +645,103 @@ class TestMeasurementStatistics:
|
|||||||
# deviation = 10.2 - nominal(10.0) = 0.2
|
# deviation = 10.2 - nominal(10.0) = 0.2
|
||||||
assert data["deviation"] is not None
|
assert data["deviation"] is not None
|
||||||
assert abs(data["deviation"] - 0.2) < 0.001
|
assert abs(data["deviation"] - 0.2) < 0.001
|
||||||
|
|
||||||
|
|
||||||
|
class TestExportCsv:
|
||||||
|
"""GET /api/measurements/export/csv tests (gitea#22, gitea#36 point 3)."""
|
||||||
|
|
||||||
|
async def test_admin_without_explicit_role_can_export(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""An admin with no MeasurementTec/Metrologist role must not get 403 (#36.3).
|
||||||
|
|
||||||
|
admin_user (conftest) already has both roles, which would pass even
|
||||||
|
without the fix: a bare admin (roles=[]) is the actual regression case.
|
||||||
|
"""
|
||||||
|
bare_admin = await _create_user(
|
||||||
|
db_session, username="bare_admin", roles=[], is_admin=True,
|
||||||
|
)
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/measurements/export/csv",
|
||||||
|
headers=auth_headers(bare_admin),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
async def test_maker_without_role_still_forbidden(
|
||||||
|
self, client: AsyncClient, maker_user: User,
|
||||||
|
):
|
||||||
|
"""A plain Maker (no MeasurementTec/Metrologist/admin) still gets 403."""
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/measurements/export/csv",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
async def test_lot_number_formula_is_neutralised(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
measurement_tec_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""A lot_number starting with '=' must not reach the CSV as a live formula (#22)."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": 10.0,
|
||||||
|
"lot_number": '=HYPERLINK("http://x","y")',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
export_resp = await client.get(
|
||||||
|
"/api/measurements/export/csv",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert export_resp.status_code == 200
|
||||||
|
rows = list(csv.reader(export_resp.text.splitlines()))
|
||||||
|
header, row = rows[0], rows[1]
|
||||||
|
lot_cell = row[header.index("lot_number")]
|
||||||
|
# Quoted with a leading ' so a spreadsheet reads it as literal text,
|
||||||
|
# not the raw formula that would otherwise be live in Excel/LibreOffice.
|
||||||
|
assert lot_cell == '\'=HYPERLINK("http://x","y")'
|
||||||
|
|
||||||
|
async def test_negative_value_stays_numeric(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
measurement_tec_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""Neutralisation must never touch numeric cells like the measured value."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": -1.25,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
export_resp = await client.get(
|
||||||
|
"/api/measurements/export/csv",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert export_resp.status_code == 200
|
||||||
|
rows = list(csv.reader(export_resp.text.splitlines()))
|
||||||
|
header, row = rows[0], rows[1]
|
||||||
|
value_cell = row[header.index("value")]
|
||||||
|
assert value_cell == "-1.250000"
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""Tests for the Alembic migration chain, and specifically for
|
||||||
|
000_base_schema.py (issue #6): a genuinely empty database has no starting
|
||||||
|
point before it, since 001-013 all assume tables 001 was written against
|
||||||
|
already exist.
|
||||||
|
|
||||||
|
These run the real `alembic upgrade head` (via `alembic.command`), not the
|
||||||
|
async app engine: `migrations/env.py` builds its own engine from
|
||||||
|
`settings.database_url`, so `Settings.database_url` is patched at the class
|
||||||
|
level (a plain instance monkeypatch cannot override a `@property`) to point
|
||||||
|
at a throwaway SQLite file for the duration of each test.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from alembic.config import Config
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
||||||
|
|
||||||
|
from src.backend.config import Settings
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
_ALEMBIC_INI = (
|
||||||
|
Path(__file__).resolve().parents[1] / "migrations" / "alembic.ini"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sqlite_database_url(tmp_path, monkeypatch):
|
||||||
|
"""Point settings.database_url (and therefore migrations/env.py) at a
|
||||||
|
throwaway SQLite file, and return its async URL."""
|
||||||
|
db_path = tmp_path / "migration_test.db"
|
||||||
|
async_url = f"sqlite+aiosqlite:///{db_path}"
|
||||||
|
monkeypatch.setattr(Settings, "database_url", property(lambda self: async_url))
|
||||||
|
return db_path, async_url
|
||||||
|
|
||||||
|
|
||||||
|
def _alembic_config() -> Config:
|
||||||
|
return Config(str(_ALEMBIC_INI))
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect(db_path: Path):
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
try:
|
||||||
|
return sa_inspect(engine)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpgradeHeadFromEmpty:
|
||||||
|
"""Full chain on a genuinely empty SQLite database (#6)."""
|
||||||
|
|
||||||
|
def test_upgrade_head_succeeds_on_empty_database(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
command.upgrade(_alembic_config(), "head") # must not raise
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert set(Base.metadata.tables) <= set(insp.get_table_names())
|
||||||
|
|
||||||
|
def test_tables_and_columns_match_base_metadata(self, sqlite_database_url):
|
||||||
|
"""Every table and column ORM models declare must exist after
|
||||||
|
`upgrade head` - the exact assertion the analysis asks for."""
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
command.upgrade(_alembic_config(), "head")
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
db_tables = set(insp.get_table_names())
|
||||||
|
|
||||||
|
for table_name, table in Base.metadata.tables.items():
|
||||||
|
assert table_name in db_tables, f"missing table {table_name}"
|
||||||
|
db_columns = {c["name"] for c in insp.get_columns(table_name)}
|
||||||
|
model_columns = {c.name for c in table.columns}
|
||||||
|
missing = model_columns - db_columns
|
||||||
|
assert not missing, f"{table_name} missing columns: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaseMigrationNoopsOnExistingSchema:
|
||||||
|
"""000_base_schema.py does nothing when `recipes` already exists (#6)."""
|
||||||
|
|
||||||
|
def test_upgrade_head_noops_000_when_pre_001_schema_already_there(
|
||||||
|
self, sqlite_database_url
|
||||||
|
):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
# Build the pre-001 schema and record it as migrated.
|
||||||
|
command.upgrade(cfg, "000_base")
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "recipes" in insp.get_table_names()
|
||||||
|
assert "image_path" not in {
|
||||||
|
c["name"] for c in insp.get_columns("recipes")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Erase the tracking: alembic now believes nothing has run, exactly
|
||||||
|
# like a database that was born from Base.metadata.create_all() and
|
||||||
|
# never stamped. Running 000's upgrade() again on a database that
|
||||||
|
# already has `recipes` must be a no-op, not a "table already
|
||||||
|
# exists" failure.
|
||||||
|
sync_engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with sync_engine.begin() as conn:
|
||||||
|
conn.execute(text("DELETE FROM alembic_version"))
|
||||||
|
sync_engine.dispose()
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head") # must not raise (no duplicate table)
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert set(Base.metadata.tables) <= set(insp.get_table_names())
|
||||||
|
assert "image_path" in {
|
||||||
|
c["name"] for c in insp.get_columns("recipes")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiKeyHashMigration:
|
||||||
|
"""015_api_key_hash: users.api_key -> api_keys table, and back (#41)."""
|
||||||
|
|
||||||
|
def test_015_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "api_keys" in insp.get_table_names()
|
||||||
|
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||||
|
assert {c["name"] for c in insp.get_columns("api_keys")} >= {
|
||||||
|
"user_id", "key_hash", "created_at", "expires_at", "last_used_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
command.downgrade(cfg, "014_vision_fk_ondelete")
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "api_keys" not in insp.get_table_names()
|
||||||
|
assert "api_key" in {c["name"] for c in insp.get_columns("users")}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head") # and up again
|
||||||
|
assert "api_keys" in _inspect(db_path).get_table_names()
|
||||||
|
|
||||||
|
def test_015_on_create_all_schema(self, sqlite_database_url):
|
||||||
|
"""DB nato da create_all: users.api_key ha solo l'indice unique
|
||||||
|
ix_users_api_key, nessun vincolo uq_users_api_key (#71)."""
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ctx = MigrationContext.configure(conn)
|
||||||
|
ops = Operations(ctx)
|
||||||
|
with ops.batch_alter_table("users") as batch:
|
||||||
|
batch.drop_index("ix_users_api_key")
|
||||||
|
batch.drop_constraint("uq_users_api_key", type_="unique")
|
||||||
|
ops.create_index("ix_users_api_key", "users", ["api_key"], unique=True)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert not insp.get_unique_constraints("users") or "uq_users_api_key" not in {
|
||||||
|
u["name"] for u in insp.get_unique_constraints("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head") # non deve sollevare
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "api_keys" in insp.get_table_names()
|
||||||
|
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||||
|
|
||||||
|
def test_015_resumes_after_partial_run(self, sqlite_database_url):
|
||||||
|
"""015 a meta' (MySQL, DDL non transazionale): api_keys creata e
|
||||||
|
ix_users_api_key tolto, alembic_version ancora 014 (#71)."""
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ops = Operations(MigrationContext.configure(conn))
|
||||||
|
ops.create_table(
|
||||||
|
"api_keys",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("key_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"], ["users.id"],
|
||||||
|
name="fk_api_keys_user_id", ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ops.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
|
||||||
|
ops.drop_index("ix_users_api_key", table_name="users")
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head") # non deve sollevare
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "api_keys" in insp.get_table_names()
|
||||||
|
assert {i["name"] for i in insp.get_indexes("api_keys")} >= {
|
||||||
|
"ix_api_keys_key_hash", "ix_api_keys_user_id",
|
||||||
|
}
|
||||||
|
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||||
|
|
||||||
|
|
||||||
|
class TestVisionRecipeDirMigration:
|
||||||
|
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
||||||
|
|
||||||
|
def test_016_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols():
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert "vision_recipe_dir" in cols()
|
||||||
|
command.downgrade(cfg, "015_api_key_hash")
|
||||||
|
assert "vision_recipe_dir" not in cols()
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert "vision_recipe_dir" in cols()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserPinMigration:
|
||||||
|
"""017_user_pin: users.pin_hash / pin_failed_count / pin_locked_until, su e giu (#59)."""
|
||||||
|
|
||||||
|
def test_017_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
new = {"pin_hash", "pin_failed_count", "pin_locked_until"}
|
||||||
|
|
||||||
|
def cols():
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns("users")}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert new <= cols()
|
||||||
|
command.downgrade(cfg, "016_vision_recipe_dir")
|
||||||
|
assert not (new & cols())
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert new <= cols()
|
||||||
|
|
||||||
|
|
||||||
|
_SEED_USER_RECIPE_VERSION = [
|
||||||
|
"INSERT INTO users (username, password_hash, display_name, roles, is_admin, active,"
|
||||||
|
" language_pref, theme_pref) VALUES ('u', 'x', 'U', '[]', 1, 1, 'en', 'light')",
|
||||||
|
"INSERT INTO recipes (code, name, active, created_by) VALUES ('R', 'R', 1, 1)",
|
||||||
|
"INSERT INTO recipe_versions (recipe_id, version_number, is_current, created_by)"
|
||||||
|
" VALUES (1, 1, 1, 1)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskLoopResultsMigration:
|
||||||
|
"""018_task_loop_results: in_loop/at_close/fields, tipo check, task_results (#53 #54 #55)."""
|
||||||
|
|
||||||
|
def test_018_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols():
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert {"in_loop", "at_close", "fields"} <= cols()
|
||||||
|
assert "task_results" in _inspect(db_path).get_table_names()
|
||||||
|
command.downgrade(cfg, "017_user_pin")
|
||||||
|
assert not ({"in_loop", "at_close", "fields"} & cols())
|
||||||
|
assert "task_results" not in _inspect(db_path).get_table_names()
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
|
||||||
|
def test_018_marks_existing_measure_tasks_in_loop(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "017_user_pin")
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for stmt in _SEED_USER_RECIPE_VERSION:
|
||||||
|
conn.execute(text(stmt))
|
||||||
|
for i, t in enumerate(("measure", "camera_measure", "dxf_compare", "note", "drawing")):
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO recipe_tasks (version_id, order_index, task_type, title)"
|
||||||
|
f" VALUES (1, {i}, '{t}', '{t}')"))
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
rows = dict(conn.execute(text("SELECT task_type, in_loop FROM recipe_tasks")).all())
|
||||||
|
engine.dispose()
|
||||||
|
assert rows == {"measure": 1, "camera_measure": 1, "dxf_compare": 1, "note": 0, "drawing": 0}
|
||||||
|
|
||||||
|
def test_018_downgrade_refused_when_a_check_task_exists(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for stmt in _SEED_USER_RECIPE_VERSION:
|
||||||
|
conn.execute(text(stmt))
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO recipe_tasks (version_id, order_index, task_type, title)"
|
||||||
|
" VALUES (1, 0, 'check', 'c')"))
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="check"):
|
||||||
|
command.downgrade(cfg, "017_user_pin")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStationVisionWorkerMigration:
|
||||||
|
"""019_station_vision_worker: stations.vision_worker_url e vision_results.worker_url, su e giu (#45)."""
|
||||||
|
|
||||||
|
def test_019_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols(table):
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert "vision_worker_url" in cols("stations")
|
||||||
|
assert "worker_url" in cols("vision_results")
|
||||||
|
command.downgrade(cfg, "018_task_loop_results")
|
||||||
|
assert "vision_worker_url" not in cols("stations")
|
||||||
|
assert "worker_url" not in cols("vision_results")
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert "vision_worker_url" in cols("stations")
|
||||||
|
assert "worker_url" in cols("vision_results")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStationCameraMigration:
|
||||||
|
"""020_station_camera: stations.camera_url e stations.camera_source, su e giu (#72)."""
|
||||||
|
|
||||||
|
def test_020_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols(table):
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||||
|
command.downgrade(cfg, "019_station_vision_worker")
|
||||||
|
assert "camera_url" not in cols("stations")
|
||||||
|
assert "camera_source" not in cols("stations")
|
||||||
|
assert "vision_worker_url" in cols("stations") # la 019 resta
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user