Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aac8cc490 | |||
| 8b3e897668 | |||
| eea3e319fb | |||
| bfc7c391e8 | |||
| 8d4c4020f6 | |||
| 959e92552b | |||
| 69cbf53060 | |||
| d89a2e6c8d | |||
| 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 |
@@ -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" }
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[submodule "vendor/visionsuite"]
|
||||
path = vendor/visionsuite
|
||||
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git
|
||||
url = ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git
|
||||
|
||||
@@ -287,9 +287,9 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
|
||||
|
||||
## Visione (V3.0.0)
|
||||
|
||||
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** su `ssh://git.tielogic.xyz:222`: un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
||||
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
||||
|
||||
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. Pin attuale: bc0df16.
|
||||
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:
|
||||
|
||||
@@ -335,3 +335,24 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
||||
- Font UI: Inter
|
||||
- Font numeri: JetBrains Mono
|
||||
- Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme`
|
||||
|
||||
## Pacchetto Windows (TieMeasureFlow#76)
|
||||
|
||||
- File: `packaging/windows/avvio.py` (entry point: licenza, `config.env`, variabili d'ambiente, migrazioni, quattro server in thread), `build.ps1` (uv sync 3.13, Tailwind, Nuitka `--standalone`, migrazioni come `.pyc`, Inno Setup), `TieMeasureFlow.iss`, `TieMeasureFlow.ico`; CI `.gitea/workflows/rilascio-windows.yml` (tag `v*`, runner con etichetta `windows`); test in `packaging/windows/tests/` (licenza firmata con la chiave di prova del contratto, server e migrazioni sostituiti). Piani collegati: SQLite su file (#77) e PDF/DLL (#78). Modello: TieVision-Studio#1.
|
||||
- Un solo processo `TieMeasureFlow.exe`: thread `web` (Flask + waitress, `--host`, 8780), `backend` (8000), `vision` (8100), `camera` (8765) con uvicorn. Dati in `%PROGRAMDATA%\Tielogic\TieMeasureFlow` (`tmf.db`, `uploads`, `camera`, `log`, `config.env`); licenza in `%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json` (mai nel repo).
|
||||
- ⚠️ Python del pacchetto: 3.13 in un ambiente separato (`build\venv313`, `UV_PROJECT_ENVIRONMENT`); il repo resta su `.python-version` 3.11 per lo sviluppo. `vs-license` e `vs-camera` hanno il marker `python_version>='3.13'` nel gruppo `windows`, altrimenti `uv lock` fallisce sul `requires-python >=3.11`.
|
||||
- ⚠️ Le variabili d'ambiente si impostano PRIMA di importare backend e frontend (leggono la configurazione all'import): in `avvio.py` gli import pesanti stanno dentro le funzioni. `APP_ENV=production` rifiuta i segreti di default, quindi `config.env` genera anche `DB_PASSWORD` (inutilizzata con SQLite).
|
||||
- ⚠️ Il frontend Flask non è un pacchetto (`from config import Config`, `blueprints`, `services`, `app`) e `scripts/camera_service.py` neppure: da sorgente `avvio.py` aggiunge `flask_app` e `scripts` al `sys.path`; per Nuitka `build.ps1` li mette su `PYTHONPATH` e li include con `--include-module/--include-package`. I dati di Flask (`templates`, `static`, `translations`) vanno nella radice della cartella standalone, `src\backend\templates` sotto `src\backend`.
|
||||
- ⚠️ Il browser parla con Flask (8780); la pagina di setup `/api/setup` invece è del backend: si apre su `http://127.0.0.1:8000/api/setup` (in Docker la serve nginx). `avvio.py` la apre da sola se il database non ha utenti.
|
||||
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
|
||||
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
|
||||
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
|
||||
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
|
||||
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
|
||||
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
|
||||
- ⚠️ `SETUP_PASSWORD` e le chiavi stanno in `config.env`, in una cartella scrivibile da tutti gli utenti locali (serve all'exe, che gira con l'utente che lo lancia): non è un luogo per segreti ad alto valore.
|
||||
- ⚠️ CI di rilascio: il runner `windows` è su daitarn (act_runner, modalità host, capacity 1); `upload-artifact` è alla v3. Non committare `build/` e `dist/`.
|
||||
- ⚠️ `STATION_CODE`: senza una stazione l'interfaccia risponde 503 («Stazione non configurata»). `config.env` nasce con `STATION_CODE=ST-DEFAULT` (la stazione creata dal seed della pagina di setup); le chiavi extra di `config.env` (`STATION_CODE`, `STATION_SWITCH_ENABLED`, ...) passano all'ambiente ma non sovrascrivono le variabili fisse del contratto.
|
||||
- ⚠️ Alembic anche fuori da `avvio.py`: `setup.py` (`_stamp_head`) apre `alembic.ini` con un `Config` suo, quindi `build.ps1` scrive `sourceless = true` nell'`alembic.ini` del pacchetto; e `env.py` chiama `logging.config.fileConfig`, che azzererebbe il log su file, quindi `avvio.py` lo sostituisce con un no-op.
|
||||
- ⚠️ Bug noto, non del pacchetto: `POST /api/setup/init-db` risponde 500 (`asyncio.run() cannot be called from a running event loop`, perché `_stamp_head` esegue `env.py` dentro il loop). Nel pacchetto non serve: le migrazioni creano già le tabelle; la pagina di setup si usa per «Seed» o per creare l'amministratore.
|
||||
- ⚠️ Le richieste di login all'API sono limitate (5 al minuto): negli script di prova distanziarle.
|
||||
|
||||
@@ -47,6 +47,13 @@ WORKDIR /app/flask_app
|
||||
RUN uv run --project /app pybabel compile -d translations && \
|
||||
chown -R app:app /app /tmp/uv-cache
|
||||
|
||||
# Commit mostrato nel piede di pagina (#70). .git e' escluso dal contesto di
|
||||
# build (.dockerignore), quindi lo passa chi costruisce; facoltativo, senza il
|
||||
# piede mostra solo la versione. Uso:
|
||||
# APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client
|
||||
ARG APP_COMMIT=""
|
||||
ENV APP_COMMIT=${APP_COMMIT}
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
@@ -333,6 +333,7 @@ TieMeasureFlow/
|
||||
|---|---|
|
||||
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
||||
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
||||
| `APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client` | Ricostruisci il client mostrando nel piè di pagina il commit (oltre alla versione di `pyproject.toml`); senza `APP_COMMIT` compare solo la versione |
|
||||
| `docker compose logs -f server` | Segui log server in tempo reale |
|
||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||
| `docker compose ps` | Stato di tutti i servizi |
|
||||
@@ -515,6 +516,70 @@ Copia `.env.example` in `.env` e configura:
|
||||
|
||||
---
|
||||
|
||||
## Camera di stazione (demo fiera, #72)
|
||||
|
||||
Per acquisire il fotogramma da una camera locale (per esempio quella del TC bench) senza passare dall'upload, sul PC della stazione gira un piccolo servizio nativo, fuori da Docker, perché la camera GigE non si raggiunge dal container. Si avvia dalla radice del repository con l'ambiente di VisionSuite (`uv sync --all-packages` in `vendor/visionsuite`):
|
||||
|
||||
```bash
|
||||
vendor/visionsuite/.venv/bin/python scripts/camera_service.py [--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
|
||||
```
|
||||
|
||||
Con `--folder` la sorgente `camera:folder` restituisce a rotazione le immagini della cartella, utile per provare senza hardware. Su Linux, perché il container `client` lo raggiunga, si usa `--host 172.17.0.1` (gateway di docker0; il compose di sviluppo mappa `host.docker.internal` su quell'host). Su Windows con Docker Desktop si presume che `host.docker.internal` raggiunga il loopback dell'host, ma va verificato sul PC della demo.
|
||||
|
||||
⚠️ Il servizio non ha autenticazione: un `--host` diverso dal loopback va usato solo su una rete isolata.
|
||||
|
||||
Nella schermata Stazioni si compilano `camera_url` (per esempio `http://host.docker.internal:8765`) e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Per avere due postazioni sullo stesso PC si abilita `STATION_SWITCH_ENABLED`.
|
||||
|
||||
---
|
||||
|
||||
## Pacchetto Windows
|
||||
|
||||
Per i PC senza Docker (per esempio il PC della stazione in fiera) TieMeasureFlow si distribuisce come programma di installazione per Windows, `TieMeasureFlow-setup-<versione>.exe`. Funziona completamente offline, non richiede Docker né MySQL, e non contiene codice sorgente Python: il codice del prodotto è compilato con Nuitka. Il programma parte solo con una licenza valida, legata al computer.
|
||||
|
||||
### Installazione
|
||||
|
||||
1. Eseguire `TieMeasureFlow-setup-<versione>.exe` (serve un account amministratore; per una prova senza amministratore si può lanciare il setup con `/CURRENTUSER`). Per un'installazione silenziosa: `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART`.
|
||||
2. Dal menu Start aprire «Impronta di questo computer» e comunicare l'impronta a Tielogic. L'impronta viene salvata anche in `C:\ProgramData\Tielogic\licenze\impronta-tiemeasureflow.txt`.
|
||||
3. Copiare il file di licenza ricevuto in `C:\ProgramData\Tielogic\licenze\tiemeasureflow.json`. La licenza non è inclusa nel setup e non va mai inserita nel repository.
|
||||
4. Avviare «TieMeasureFlow» dal menu Start o dal desktop. Senza una licenza valida (mancante, scaduta, di un altro computer o di un altro prodotto) il programma mostra il motivo e l'impronta ed esce.
|
||||
|
||||
### Primo avvio
|
||||
|
||||
Il programma gira in un solo processo, senza finestra, e apre il browser. Ascolta solo sul computer locale (`127.0.0.1`); per raggiungere l'interfaccia da altri dispositivi della rete si avvia l'exe con `--host 0.0.0.0` (per esempio da un collegamento personalizzato).
|
||||
|
||||
| Componente | Indirizzo |
|
||||
|---|---|
|
||||
| Interfaccia web (Flask) | http://127.0.0.1:8780/ |
|
||||
| Backend API (FastAPI) | http://127.0.0.1:8000/ |
|
||||
| Worker di visione | http://127.0.0.1:8100/ |
|
||||
| Camera della stazione | http://127.0.0.1:8765/ (`/camera/sources`) |
|
||||
|
||||
Al primo avvio vengono create la cartella dati `C:\ProgramData\Tielogic\TieMeasureFlow` e il file `config.env` con le chiavi segrete generate (`SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY`, `SETUP_PASSWORD`, `DB_PASSWORD`); al riavvio vengono rilette, quindi le sessioni e le password restano valide. Il database è un file SQLite (`tmf.db`); le migrazioni si applicano da sole a ogni avvio.
|
||||
|
||||
Se il database non contiene ancora utenti, il browser si apre direttamente sulla pagina di setup `http://127.0.0.1:8000/api/setup`. La pagina chiede la `SETUP_PASSWORD`, che si legge in `config.env`, e permette di inizializzare il database e creare il primo amministratore (vedi «Setup Iniziale»). Terminato il setup si accede all'interfaccia su `http://127.0.0.1:8780/`.
|
||||
|
||||
Per le camere della stazione si compila nella schermata Stazioni `camera_url` = `http://127.0.0.1:8765` e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Con `camera:folder` il servizio restituisce a rotazione le immagini che si copiano in `C:\ProgramData\Tielogic\TieMeasureFlow\camera`. Per le camere GigE/GenTL il driver (producer `.cti`) è quello installato dal produttore della camera.
|
||||
|
||||
Altre informazioni utili:
|
||||
|
||||
- I log sono in `C:\ProgramData\Tielogic\TieMeasureFlow\log\tiemeasureflow.log`.
|
||||
- Una seconda esecuzione mentre il programma è già attivo apre soltanto il browser.
|
||||
- Per fermare il programma si usa «Arresta TieMeasureFlow» nel menu Start (chiude il processo).
|
||||
- La disinstallazione non cancella licenze, database, file caricati e `config.env`: sono dati del cliente.
|
||||
- I report PDF usano le librerie grafiche incluse nel pacchetto e un browser Chrome o Edge già installato sul computer per i grafici.
|
||||
|
||||
### Come si costruisce
|
||||
|
||||
Il setup lo produce la CI Gitea (`.gitea/workflows/rilascio-windows.yml`) quando si crea un tag `v*`, su un runner con etichetta `windows`; il file compare come allegato della release. In locale, dalla radice del repository con il sottomodulo `vendor/visionsuite` inizializzato:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione 3.0.0
|
||||
```
|
||||
|
||||
Servono `uv`, Node.js, Visual Studio con il carico C++ e Inno Setup 6. Il risultato è `dist\TieMeasureFlow-setup-3.0.0.exe`. La prima compilazione richiede decine di minuti. I test dell'avvio si lanciano con l'ambiente Python 3.13 del pacchetto: `uv run --python 3.13 --extra server --extra client --extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q`.
|
||||
|
||||
---
|
||||
|
||||
## Documentazione
|
||||
|
||||
Indice completo: [`docs/README.md`](docs/README.md).
|
||||
|
||||
@@ -75,6 +75,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
args:
|
||||
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||
APP_COMMIT: ${APP_COMMIT:-}
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@@ -85,6 +88,9 @@ services:
|
||||
# on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure,
|
||||
# altrimenti il browser non lo rimanda mai indietro.
|
||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
||||
# #72: da container si raggiunge il servizio camera (vs-gui) sull'host Linux
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -89,6 +89,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
args:
|
||||
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||
APP_COMMIT: ${APP_COMMIT:-}
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,81 @@
|
||||
; Installer di TieMeasureFlow (TieMeasureFlow#76).
|
||||
; Cosa fa: copia la cartella standalone di Nuitka in Program Files\Tielogic\TieMeasureFlow,
|
||||
; crea in ProgramData le cartelle scrivibili da tutti gli utenti (licenze, uploads, camera, log)
|
||||
; e i collegamenti in menu Start e sul desktop.
|
||||
; Come si usa: lo lancia build.ps1 con /DVersione /DVersione4 /DOrigine /DUscita; a mano:
|
||||
; ISCC /DVersione=3.0.0 /DVersione4=3.0.0.0 /DOrigine=build\nuitka\avvio.dist /DUscita=dist TieMeasureFlow.iss
|
||||
; Setup silenzioso: TieMeasureFlow-setup-X.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||
; (/CURRENTUSER per installare senza amministratore, in %LOCALAPPDATA%\Programs).
|
||||
; ATTENZIONE: la licenza NON e' nel setup: va copiata in {commonappdata}\Tielogic\licenze\tiemeasureflow.json
|
||||
; (la cartella e' scrivibile dagli utenti). La disinstallazione lascia licenze, database (tmf.db),
|
||||
; uploads e config.env: sono dati del cliente.
|
||||
|
||||
#ifndef Versione
|
||||
#define Versione "0.0.0"
|
||||
#endif
|
||||
#ifndef Versione4
|
||||
#define Versione4 "0.0.0.0"
|
||||
#endif
|
||||
#ifndef Origine
|
||||
#define Origine "..\..\build\nuitka\avvio.dist"
|
||||
#endif
|
||||
#ifndef Uscita
|
||||
#define Uscita "..\..\dist"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
; AppId proprio di TieMeasureFlow (diverso da quello di TieVision Studio)
|
||||
AppId={{B7D4E2A1-5C93-4F6E-8A0B-3D1F7E9C2A64}
|
||||
AppName=TieMeasureFlow
|
||||
AppVersion={#Versione}
|
||||
AppVerName=TieMeasureFlow {#Versione}
|
||||
AppPublisher=Tielogic
|
||||
DefaultDirName={autopf}\Tielogic\TieMeasureFlow
|
||||
DefaultGroupName=Tielogic\TieMeasureFlow
|
||||
PrivilegesRequired=admin
|
||||
; /CURRENTUSER solo da riga di comando: serve alle prove senza amministratore
|
||||
PrivilegesRequiredOverridesAllowed=commandline
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
OutputDir={#Uscita}
|
||||
OutputBaseFilename=TieMeasureFlow-setup-{#Versione}
|
||||
SetupIconFile=TieMeasureFlow.ico
|
||||
UninstallDisplayIcon={app}\TieMeasureFlow.exe
|
||||
VersionInfoVersion={#Versione4}
|
||||
VersionInfoCompany=Tielogic
|
||||
VersionInfoProductName=TieMeasureFlow
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
DisableProgramGroupPage=yes
|
||||
; il programma in esecuzione tiene aperti i file: Inno chiede di chiuderlo (in silenzioso lo chiude)
|
||||
CloseApplications=yes
|
||||
|
||||
[Languages]
|
||||
Name: "italiano"; MessagesFile: "compiler:Languages\Italian.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Crea un collegamento sul desktop"; GroupDescription: "Collegamenti:"
|
||||
|
||||
[Dirs]
|
||||
; scrivibili da tutti gli utenti: l'exe gira con l'utente che lo lancia, non con admin
|
||||
Name: "{commonappdata}\Tielogic\licenze"; Permissions: users-modify
|
||||
Name: "{commonappdata}\Tielogic\TieMeasureFlow"; Permissions: users-modify
|
||||
Name: "{commonappdata}\Tielogic\TieMeasureFlow\uploads"; Permissions: users-modify
|
||||
Name: "{commonappdata}\Tielogic\TieMeasureFlow\camera"; Permissions: users-modify
|
||||
Name: "{commonappdata}\Tielogic\TieMeasureFlow\log"; Permissions: users-modify
|
||||
|
||||
[Files]
|
||||
Source: "{#Origine}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"
|
||||
Name: "{group}\Impronta di questo computer"; Filename: "{app}\TieMeasureFlow.exe"; Parameters: "--impronta"
|
||||
; l'exe non ha finestra: per fermarlo si chiude il processo
|
||||
Name: "{group}\Arresta TieMeasureFlow"; Filename: "{sys}\taskkill.exe"; Parameters: "/IM TieMeasureFlow.exe /F"; Flags: runminimized
|
||||
Name: "{group}\Cartella dati e configurazione"; Filename: "{commonappdata}\Tielogic\TieMeasureFlow"
|
||||
Name: "{group}\Disinstalla TieMeasureFlow"; Filename: "{uninstallexe}"
|
||||
Name: "{autodesktop}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\TieMeasureFlow.exe"; Description: "Avvia TieMeasureFlow"; Flags: nowait postinstall skipifsilent
|
||||
@@ -0,0 +1,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,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
|
||||
@@ -81,6 +81,20 @@ vision-worker = [
|
||||
"python-multipart>=0.0.6; python_version>='3.13'",
|
||||
]
|
||||
|
||||
# Pacchetto Windows (packaging/windows, TieMeasureFlow#76): solo per build.ps1, non per
|
||||
# i container. Si usa con --python 3.13 insieme agli extra server, client e vision-worker.
|
||||
[dependency-groups]
|
||||
windows = [
|
||||
"tzdata; sys_platform == 'win32'", # ZoneInfo("Europe/Rome") (backend e frontend): Windows non ha il database IANA
|
||||
"aiosqlite>=0.20.0", # database SQLite su file (DB_URL, piano A)
|
||||
"waitress>=3.0.0", # server WSGI del frontend Flask (gunicorn non gira su Windows)
|
||||
"vs-camera[genicam]; python_version>='3.13'", # scripts/camera_service.py: camere GigE/GenTL (producer .cti del vendor)
|
||||
"vs-license; python_version>='3.13'", # licenza Tielogic v1 (impronta macchina, firma Ed25519)
|
||||
"nuitka>=2.5", # compilazione standalone
|
||||
"ordered-set>=4.1", # richiesto da Nuitka per la modalita' standalone
|
||||
"zstandard>=0.22", # compressione dei dati incorporati di Nuitka
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
# Backend
|
||||
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
||||
@@ -105,6 +119,8 @@ vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true
|
||||
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
||||
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
|
||||
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
|
||||
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true } # #76 camera di stazione
|
||||
vs-license = { path = "vendor/visionsuite/packages/vs-license", editable = true } # #76 licenza del pacchetto Windows
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Servizio camera minimo di stazione (TieMeasureFlow, #72).
|
||||
|
||||
Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
|
||||
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
||||
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
||||
GET /camera/sources -> {"sources": [{name, kind, label}]}
|
||||
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG
|
||||
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
||||
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
|
||||
raggiunge; questo servizio permette di scegliere l'host di ascolto.
|
||||
|
||||
Uso (dalla radice del repo):
|
||||
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \
|
||||
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
|
||||
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
||||
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
|
||||
|
||||
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
||||
vs-camera, fastapi, uvicorn, opencv.
|
||||
|
||||
AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
|
||||
camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
from fastapi import Body, FastAPI, HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from visionsuite.camera.camera_interface import CameraError, CameraGrabError
|
||||
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
|
||||
|
||||
_TRIGGER = ("continuo",)
|
||||
|
||||
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
|
||||
_MESSAGGI = {
|
||||
"Camera is not open": "La camera non è aperta",
|
||||
"Camera is not grabbing": "La camera non sta acquisendo",
|
||||
"Grab cancelled (grabbing stopped)": "Acquisizione annullata (acquisizione fermata)",
|
||||
"Failed to read frame from RTSP stream": "Lettura del fotogramma dallo stream RTSP non riuscita",
|
||||
}
|
||||
_PREFISSI = (
|
||||
("Failed to open OpenCV device", "Apertura del dispositivo OpenCV non riuscita"),
|
||||
("Failed to open RTSP stream", "Apertura dello stream RTSP non riuscita"),
|
||||
("Failed to reconnect to RTSP stream", "Riconnessione allo stream RTSP non riuscita"),
|
||||
("Failed to read frame after", "Lettura del fotogramma non riuscita dopo"),
|
||||
)
|
||||
|
||||
|
||||
def _traduci(messaggio: str) -> str:
|
||||
if messaggio in _MESSAGGI:
|
||||
return _MESSAGGI[messaggio]
|
||||
for inglese, italiano in _PREFISSI:
|
||||
if messaggio.startswith(inglese):
|
||||
return italiano + messaggio[len(inglese):]
|
||||
return messaggio
|
||||
|
||||
|
||||
def create_app(folder: Path | str | None = None) -> FastAPI:
|
||||
aperte: dict = {}
|
||||
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||
|
||||
def _config() -> dict:
|
||||
if folder is None:
|
||||
return {}
|
||||
return {"folder": {"folder": str(folder), "loop": True}}
|
||||
|
||||
def _lock_di(nome: str) -> threading.Lock:
|
||||
with lock:
|
||||
return lock_sorgenti.setdefault(nome, threading.Lock())
|
||||
|
||||
@asynccontextmanager
|
||||
async def _vita(_app):
|
||||
yield
|
||||
with lock: # alla chiusura rilascia le camere aperte
|
||||
sorgenti = list(aperte.values())
|
||||
aperte.clear()
|
||||
for s in sorgenti:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
app = FastAPI(title="TMF camera service", lifespan=_vita)
|
||||
|
||||
@app.get("/camera/sources")
|
||||
def camera_sources():
|
||||
voci = list_sources(None, cameras=True, config=_config())
|
||||
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual")]}
|
||||
|
||||
@app.post("/camera/grab")
|
||||
def camera_grab(corpo: dict = Body(default=None)):
|
||||
nome = corpo.get("source") if isinstance(corpo, dict) else None
|
||||
if not isinstance(nome, str) or not nome.strip():
|
||||
raise HTTPException(400, "manca «source»")
|
||||
nome = nome.strip()
|
||||
trigger = corpo.get("trigger", "continuo")
|
||||
if trigger not in _TRIGGER:
|
||||
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
||||
timeout = corpo.get("timeout_ms", 5000)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||
try:
|
||||
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
|
||||
with lock:
|
||||
sorgente = aperte.get(nome)
|
||||
if sorgente is None:
|
||||
sorgente = open_source(nome, None, config=_config())
|
||||
with lock:
|
||||
aperte[nome] = sorgente
|
||||
frame = sorgente.grab(timeout)
|
||||
except SourceNotFound as e:
|
||||
raise HTTPException(404, _traduci(str(e)))
|
||||
except TimeoutError as e:
|
||||
raise HTTPException(504, _traduci(str(e)) or "timeout")
|
||||
except (CameraGrabError, CameraError) as e:
|
||||
raise HTTPException(503, _traduci(str(e)))
|
||||
ok, buf = cv2.imencode(".png", frame)
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica PNG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/png")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
p = argparse.ArgumentParser(description="Servizio camera minimo di stazione (#72)")
|
||||
p.add_argument("--host", default="127.0.0.1")
|
||||
p.add_argument("--port", type=int, default=8765)
|
||||
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder")
|
||||
a = p.parse_args()
|
||||
uvicorn.run(create_app(a.folder), host=a.host, port=a.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Test di scripts/camera_service.py (#72).
|
||||
|
||||
Uso: vendor/visionsuite/.venv/bin/python -m pytest scripts/tests/test_camera_service.py -q
|
||||
Dipende da: venv di VisionSuite (vs-camera, fastapi, httpx, opencv, pytest).
|
||||
"""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||
|
||||
|
||||
def _carica():
|
||||
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella(tmp_path):
|
||||
for nome, (w, h) in {"a.png": (64, 48), "b.png": (64, 48)}.items():
|
||||
cv2.imwrite(str(tmp_path / nome), np.full((h, w, 3), 128, np.uint8))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(cartella):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella)) as c:
|
||||
c.mod = mod
|
||||
yield c
|
||||
|
||||
|
||||
def test_sources_contiene_folder(client):
|
||||
r = client.get("/camera/sources")
|
||||
assert r.status_code == 200
|
||||
nomi = [s["name"] for s in r.json()["sources"]]
|
||||
assert "camera:folder" in nomi
|
||||
voce = next(s for s in r.json()["sources"] if s["name"] == "camera:folder")
|
||||
assert {"name", "kind", "label"} <= set(voce)
|
||||
|
||||
|
||||
def test_grab_folder_restituisce_png(client):
|
||||
r = client.post("/camera/grab", json={"source": "camera:folder"})
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/png"
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert img.shape[:2] == (48, 64)
|
||||
|
||||
|
||||
def test_grab_ripetuto_in_loop(client):
|
||||
# due immagini in cartella, tre grab: il loop non deve esaurirsi
|
||||
for _ in range(3):
|
||||
assert client.post("/camera/grab", json={"source": "camera:folder"}).status_code == 200
|
||||
|
||||
|
||||
def test_source_mancante_400(client):
|
||||
assert client.post("/camera/grab", json={}).status_code == 400
|
||||
assert client.post("/camera/grab", json={"source": " "}).status_code == 400
|
||||
|
||||
|
||||
def test_source_inesistente_404(client):
|
||||
assert client.post("/camera/grab", json={"source": "camera:non-esiste"}).status_code == 404
|
||||
|
||||
|
||||
def test_timeout_non_valido_400(client):
|
||||
r = client.post("/camera/grab", json={"source": "camera:folder", "timeout_ms": 0})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_errore_camera_503_tradotto(client, monkeypatch):
|
||||
from visionsuite.camera.camera_interface import CameraGrabError
|
||||
|
||||
class Rotta:
|
||||
def grab(self, timeout):
|
||||
raise CameraGrabError("Camera is not open")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Rotta())
|
||||
r = client.post("/camera/grab", json={"source": "camera:finta"})
|
||||
assert r.status_code == 503
|
||||
assert r.json()["detail"] == "La camera non è aperta"
|
||||
|
||||
|
||||
def test_timeout_504(client, monkeypatch):
|
||||
class Lenta:
|
||||
def grab(self, timeout):
|
||||
raise TimeoutError("scaduto")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Lenta())
|
||||
r = client.post("/camera/grab", json={"source": "camera:lenta"})
|
||||
assert r.status_code == 504
|
||||
|
||||
|
||||
def test_sorgente_aperta_una_volta(client, monkeypatch):
|
||||
aperture = []
|
||||
reale = client.mod.open_source
|
||||
|
||||
def conta(*a, **k):
|
||||
aperture.append(a[0])
|
||||
return reale(*a, **k)
|
||||
|
||||
monkeypatch.setattr(client.mod, "open_source", conta)
|
||||
for _ in range(3):
|
||||
client.post("/camera/grab", json={"source": "camera:folder"})
|
||||
assert aperture == ["camera:folder"]
|
||||
@@ -67,6 +67,21 @@ async def create_new_station(
|
||||
# The int-typed station_id param already guards against "by-code" being
|
||||
# matched as a station id, but keeping the explicit order avoids surprises
|
||||
# during refactors (e.g. if someone regroups handlers by HTTP method).
|
||||
@router.get("/by-code/{code}", response_model=StationResponse)
|
||||
async def get_station_by_code(
|
||||
code: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a station by its code (any authenticated user, like /by-code/{code}/recipes) (#72)."""
|
||||
station = await station_service.get_station_by_code(db, code)
|
||||
if station is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Station not found",
|
||||
)
|
||||
return StationResponse.model_validate(station)
|
||||
|
||||
|
||||
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
|
||||
async def list_recipes_by_station_code(
|
||||
code: str,
|
||||
|
||||
@@ -12,6 +12,7 @@ from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.statistics import (
|
||||
CapabilityData,
|
||||
@@ -294,3 +295,76 @@ async def get_recipe_runs(
|
||||
}
|
||||
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}
|
||||
|
||||
@@ -22,24 +22,44 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||
),
|
||||
)
|
||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||
# Idempotent on purpose (#71): a database born from create_all has no
|
||||
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
|
||||
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
|
||||
# created and `alembic_version` still at 014. Every step checks first.
|
||||
insp = sa.inspect(op.get_bind())
|
||||
|
||||
if 'api_keys' not in insp.get_table_names():
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||
),
|
||||
)
|
||||
existing_idx: set[str] = set()
|
||||
else:
|
||||
existing_idx = {i['name'] for i in insp.get_indexes('api_keys')}
|
||||
|
||||
if 'ix_api_keys_key_hash' not in existing_idx:
|
||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||
if 'ix_api_keys_user_id' not in existing_idx:
|
||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||
|
||||
if 'api_key' not in {c['name'] for c in insp.get_columns('users')}:
|
||||
return # column already gone: nothing else to do
|
||||
|
||||
user_idx = {i['name'] for i in insp.get_indexes('users')}
|
||||
user_uq = {u['name'] for u in insp.get_unique_constraints('users')}
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.drop_index('ix_users_api_key')
|
||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||
if 'ix_users_api_key' in user_idx:
|
||||
batch.drop_index('ix_users_api_key')
|
||||
if 'uq_users_api_key' in user_uq:
|
||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||
batch.drop_column('api_key')
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""stations.vision_worker_url e vision_results.worker_url (#45)
|
||||
|
||||
stations.vision_worker_url: worker di visione della stazione (NULL = centrale).
|
||||
vision_results.worker_url: worker effettivamente chiamato per quel risultato.
|
||||
|
||||
Revision ID: 019_station_vision_worker
|
||||
Revises: 018_task_loop_results
|
||||
Create Date: 2026-10-11
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '019_station_vision_worker'
|
||||
down_revision: Union[str, None] = '018_task_loop_results'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.add_column(sa.Column('vision_worker_url', sa.String(500), nullable=True))
|
||||
with op.batch_alter_table('vision_results') as batch:
|
||||
batch.add_column(sa.Column('worker_url', sa.String(500), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('vision_results') as batch:
|
||||
batch.drop_column('worker_url')
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.drop_column('vision_worker_url')
|
||||
@@ -0,0 +1,33 @@
|
||||
"""stations.camera_url e stations.camera_source (#72)
|
||||
|
||||
stations.camera_url: servizio camera della stazione (vs-gui o equivalente).
|
||||
stations.camera_source: sorgente da chiedere al servizio, es. camera:<seriale>
|
||||
o camera:folder.
|
||||
|
||||
Revision ID: 020_station_camera
|
||||
Revises: 019_station_vision_worker
|
||||
Create Date: 2026-10-15
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '020_station_camera'
|
||||
down_revision: Union[str, None] = '019_station_vision_worker'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.add_column(sa.Column('camera_url', sa.String(500), nullable=True))
|
||||
batch.add_column(sa.Column('camera_source', sa.String(200), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.drop_column('camera_source')
|
||||
batch.drop_column('camera_url')
|
||||
@@ -2,7 +2,36 @@
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
def _normalize_http_url(value: Optional[str], field: str) -> Optional[str]:
|
||||
"""Vuoto/spazi -> None; altrimenti http(s) senza '/' finale."""
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
if not value.lower().startswith(("http://", "https://")):
|
||||
raise ValueError(f"{field} must start with http:// or https://")
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
|
||||
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
|
||||
return _normalize_http_url(value, "vision_worker_url")
|
||||
|
||||
|
||||
def _normalize_camera_url(value: Optional[str]) -> Optional[str]:
|
||||
"""Vuoto/spazi -> None (nessuna camera); altrimenti http(s) senza '/' finale (#72)."""
|
||||
return _normalize_http_url(value, "camera_url")
|
||||
|
||||
|
||||
def _normalize_camera_source(value: Optional[str]) -> Optional[str]:
|
||||
"""Strip; vuoto -> None (#72)."""
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip() or None
|
||||
|
||||
|
||||
class StationCreate(BaseModel):
|
||||
@@ -11,6 +40,15 @@ class StationCreate(BaseModel):
|
||||
location: Optional[str] = Field(default=None, max_length=255)
|
||||
notes: Optional[str] = None
|
||||
active: bool = True
|
||||
# worker di visione della stazione (#45); vuoto = centrale
|
||||
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||
# camera della stazione (#72); vuoto = nessuna camera
|
||||
camera_url: Optional[str] = Field(default=None, max_length=500)
|
||||
camera_source: Optional[str] = Field(default=None, max_length=200)
|
||||
|
||||
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
|
||||
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
|
||||
|
||||
|
||||
class StationUpdate(BaseModel):
|
||||
@@ -18,6 +56,15 @@ class StationUpdate(BaseModel):
|
||||
location: Optional[str] = Field(default=None, max_length=255)
|
||||
notes: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
# worker di visione della stazione (#45); "" -> None riporta al centrale
|
||||
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||
# camera della stazione (#72); vuoto = nessuna camera
|
||||
camera_url: Optional[str] = Field(default=None, max_length=500)
|
||||
camera_source: Optional[str] = Field(default=None, max_length=200)
|
||||
|
||||
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
|
||||
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
|
||||
|
||||
|
||||
class StationResponse(BaseModel):
|
||||
@@ -28,6 +75,9 @@ class StationResponse(BaseModel):
|
||||
location: Optional[str]
|
||||
notes: Optional[str]
|
||||
active: bool
|
||||
vision_worker_url: Optional[str] = None # #45
|
||||
camera_url: Optional[str] = None # #72
|
||||
camera_source: Optional[str] = None # #72
|
||||
created_by: int
|
||||
created_at: UtcDateTime
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ class Station(Base):
|
||||
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
# URL del worker di visione della stazione (#45); NULL = worker centrale
|
||||
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
# camera della stazione (#72): URL del servizio camera e sorgente da chiedergli
|
||||
camera_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
camera_source: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
|
||||
@@ -26,6 +26,8 @@ class VisionResult(Base):
|
||||
Integer, ForeignKey("stations.id"), nullable=True
|
||||
)
|
||||
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
# URL del worker effettivamente chiamato (#45), copiato: la stazione puo' cambiarlo dopo
|
||||
worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
# Copied, not referenced: a calibration redone six months from now must not
|
||||
# rewrite what a measurement already taken means. Same for the graph.
|
||||
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
@@ -31,6 +31,9 @@ async def create_station(
|
||||
location=data.location,
|
||||
notes=data.notes,
|
||||
active=data.active,
|
||||
vision_worker_url=data.vision_worker_url, # #45
|
||||
camera_url=data.camera_url, # #72
|
||||
camera_source=data.camera_source, # #72
|
||||
created_by=creator.id,
|
||||
)
|
||||
db.add(station)
|
||||
|
||||
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
from src.backend.services import measurement_service
|
||||
@@ -53,6 +54,7 @@ async def _call_worker(
|
||||
*,
|
||||
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.
|
||||
|
||||
@@ -70,6 +72,8 @@ async def _call_worker(
|
||||
# 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:
|
||||
@@ -77,19 +81,19 @@ async def _call_worker(
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.vision_worker_url}/run",
|
||||
f"{base}/run",
|
||||
files={"image": ("frame.png", image_bytes, "image/png")},
|
||||
data=data,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail=f"the vision worker did not answer in time: {exc}",
|
||||
detail=f"the vision worker did not answer in time: {exc} ({base})", # #45
|
||||
) from exc
|
||||
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker is unreachable: {exc}",
|
||||
detail=f"the vision worker is unreachable: {exc} ({base})", # #45
|
||||
) from exc
|
||||
|
||||
# The worker already gave this fault a name - forward its status and
|
||||
@@ -352,12 +356,20 @@ async def execute_task(
|
||||
detail="this recipe is not active",
|
||||
)
|
||||
|
||||
run = None
|
||||
if production_run_id is not None:
|
||||
# 404 if the run does not exist, 422 if it belongs to another recipe,
|
||||
# 409 if it is closed or paused - same rule a manual reading follows.
|
||||
await measurement_service.validate_run_for_version(
|
||||
run = await measurement_service.validate_run_for_version(
|
||||
db, production_run_id, version_id,
|
||||
)
|
||||
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||
station_url = None
|
||||
if run is not None:
|
||||
station = (await db.execute(
|
||||
select(Station).where(Station.id == run.station_id)
|
||||
)).scalar_one_or_none()
|
||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||
|
||||
if not task.vision_json:
|
||||
raise HTTPException(
|
||||
@@ -411,6 +423,7 @@ async def execute_task(
|
||||
# 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"]
|
||||
|
||||
@@ -439,7 +452,10 @@ async def execute_task(
|
||||
image_path=image_path,
|
||||
overlay_path=overlay_path,
|
||||
engine_version=report["engine_version"],
|
||||
executed_on="server",
|
||||
# #45: dove e' girato davvero, e quale worker (copiato come lo snapshot)
|
||||
executed_on="station" if station_url else "server",
|
||||
station_id=run.station_id if run is not None else None,
|
||||
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||
graph_snapshot=task.vision_json,
|
||||
duration_ms=report.get("duration_ms"),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,10 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.operations import Operations
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.config import Config
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
||||
|
||||
from src.backend.config import Settings
|
||||
@@ -132,6 +135,70 @@ class TestApiKeyHashMigration:
|
||||
command.upgrade(cfg, "head") # and up again
|
||||
assert "api_keys" in _inspect(db_path).get_table_names()
|
||||
|
||||
def test_015_on_create_all_schema(self, sqlite_database_url):
|
||||
"""DB nato da create_all: users.api_key ha solo l'indice unique
|
||||
ix_users_api_key, nessun vincolo uq_users_api_key (#71)."""
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
ops = Operations(ctx)
|
||||
with ops.batch_alter_table("users") as batch:
|
||||
batch.drop_index("ix_users_api_key")
|
||||
batch.drop_constraint("uq_users_api_key", type_="unique")
|
||||
ops.create_index("ix_users_api_key", "users", ["api_key"], unique=True)
|
||||
engine.dispose()
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert not insp.get_unique_constraints("users") or "uq_users_api_key" not in {
|
||||
u["name"] for u in insp.get_unique_constraints("users")
|
||||
}
|
||||
|
||||
command.upgrade(cfg, "head") # non deve sollevare
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert "api_keys" in insp.get_table_names()
|
||||
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||
|
||||
def test_015_resumes_after_partial_run(self, sqlite_database_url):
|
||||
"""015 a meta' (MySQL, DDL non transazionale): api_keys creata e
|
||||
ix_users_api_key tolto, alembic_version ancora 014 (#71)."""
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
ops = Operations(MigrationContext.configure(conn))
|
||||
ops.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("key_hash", sa.String(64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"], ["users.id"],
|
||||
name="fk_api_keys_user_id", ondelete="CASCADE",
|
||||
),
|
||||
)
|
||||
ops.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
|
||||
ops.drop_index("ix_users_api_key", table_name="users")
|
||||
engine.dispose()
|
||||
|
||||
command.upgrade(cfg, "head") # non deve sollevare
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert "api_keys" in insp.get_table_names()
|
||||
assert {i["name"] for i in insp.get_indexes("api_keys")} >= {
|
||||
"ix_api_keys_key_hash", "ix_api_keys_user_id",
|
||||
}
|
||||
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||
|
||||
|
||||
class TestVisionRecipeDirMigration:
|
||||
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
||||
@@ -233,3 +300,44 @@ class TestTaskLoopResultsMigration:
|
||||
|
||||
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")
|
||||
|
||||
@@ -374,3 +374,159 @@ async def test_reset_unknown_station_is_404(client: AsyncClient, admin_user):
|
||||
"/api/stations/999999/recipes", headers=auth_headers(admin_user),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- vision_worker_url (#45) ---
|
||||
|
||||
async def test_station_vision_worker_url_roundtrip(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"code": "ST-VW", "name": "VW", "vision_worker_url": " http://10.0.0.7:8100/ "},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
# spazi tolti e "/" finale tolto
|
||||
assert created.json()["vision_worker_url"] == "http://10.0.0.7:8100"
|
||||
|
||||
sid = created.json()["id"]
|
||||
updated = await client.put(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
json={"vision_worker_url": "https://w.example:9000"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["vision_worker_url"] == "https://w.example:9000"
|
||||
|
||||
# stringa vuota = torna al worker centrale
|
||||
cleared = await client.put(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
json={"vision_worker_url": ""},
|
||||
)
|
||||
assert cleared.status_code == 200
|
||||
assert cleared.json()["vision_worker_url"] is None
|
||||
|
||||
|
||||
async def test_station_vision_worker_url_defaults_to_null(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-VN", "name": "VN"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert created.json()["vision_worker_url"] is None
|
||||
|
||||
|
||||
async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin_user):
|
||||
resp = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-VF", "name": "VF", "vision_worker_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-VG", "name": "VG"},
|
||||
)
|
||||
resp = await client.put(
|
||||
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
|
||||
json={"vision_worker_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# --- camera_url / camera_source (#72) ---
|
||||
|
||||
async def test_station_camera_fields_roundtrip(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={
|
||||
"code": "ST-CAM", "name": "CAM",
|
||||
"camera_url": " http://127.0.0.1:8765/ ",
|
||||
"camera_source": " camera:folder ",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
assert created.json()["camera_url"] == "http://127.0.0.1:8765"
|
||||
assert created.json()["camera_source"] == "camera:folder"
|
||||
|
||||
sid = created.json()["id"]
|
||||
read = await client.get(f"/api/stations/{sid}", headers=auth_headers(admin_user))
|
||||
assert read.status_code == 200
|
||||
assert read.json()["camera_url"] == "http://127.0.0.1:8765"
|
||||
assert read.json()["camera_source"] == "camera:folder"
|
||||
|
||||
updated = await client.put(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
json={"camera_url": "https://cam.example:9000", "camera_source": "camera:ABC123"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["camera_url"] == "https://cam.example:9000"
|
||||
assert updated.json()["camera_source"] == "camera:ABC123"
|
||||
|
||||
cleared = await client.put(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
json={"camera_url": "", "camera_source": " "},
|
||||
)
|
||||
assert cleared.status_code == 200
|
||||
assert cleared.json()["camera_url"] is None
|
||||
assert cleared.json()["camera_source"] is None
|
||||
|
||||
|
||||
async def test_station_camera_fields_default_to_null(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-CN", "name": "CN"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert created.json()["camera_url"] is None
|
||||
assert created.json()["camera_source"] is None
|
||||
|
||||
|
||||
async def test_station_camera_url_must_be_http(client: AsyncClient, admin_user):
|
||||
resp = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-CF", "name": "CF", "camera_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-CG", "name": "CG"},
|
||||
)
|
||||
resp = await client.put(
|
||||
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
|
||||
json={"camera_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_station_by_code_returns_station(client: AsyncClient, admin_user):
|
||||
await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-BC", "name": "BC", "camera_url": "http://h:1", "camera_source": "camera:folder"},
|
||||
)
|
||||
resp = await client.get("/api/stations/by-code/ST-BC", headers=auth_headers(admin_user))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["code"] == "ST-BC"
|
||||
assert resp.json()["camera_url"] == "http://h:1"
|
||||
assert resp.json()["camera_source"] == "camera:folder"
|
||||
|
||||
|
||||
async def test_station_by_code_unknown_is_404(client: AsyncClient, admin_user):
|
||||
resp = await client.get("/api/stations/by-code/NOPE", headers=auth_headers(admin_user))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_station_by_code_any_authenticated_user(
|
||||
client: AsyncClient, measurement_tec_user, admin_user,
|
||||
):
|
||||
await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-BA", "name": "BA"},
|
||||
)
|
||||
resp = await client.get("/api/stations/by-code/ST-BA", headers=auth_headers(measurement_tec_user))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["code"] == "ST-BA"
|
||||
|
||||
|
||||
async def test_station_by_code_requires_auth(client: AsyncClient):
|
||||
resp = await client.get("/api/stations/by-code/ST-BA")
|
||||
assert resp.status_code == 401
|
||||
|
||||
@@ -287,3 +287,116 @@ class TestRunFilter:
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
params={"recipe_id": 1})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestRunTaskResults:
|
||||
"""#58: GET /api/statistics/runs/{id}/task-results."""
|
||||
|
||||
FIELDS = [
|
||||
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
|
||||
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||
]
|
||||
|
||||
async def _setup(self, client, db_session, maker_user, tec_user):
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task_result import TaskResult
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TR58")
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().first()
|
||||
db_session.add(Station(code="ST-TR58", name="S", active=True, created_by=maker_user.id))
|
||||
await db_session.commit()
|
||||
station = (await db_session.execute(select(Station))).scalars().first()
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=tec_user.id, status="open", started_at=datetime(2026, 1, 1),
|
||||
)
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
h = auth_headers(maker_user)
|
||||
check = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||
json={"title": "Controllo", "task_type": "check"},
|
||||
)).json()
|
||||
note = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||
json={"title": "Nota", "task_type": "note", "fields": self.FIELDS},
|
||||
)).json()
|
||||
# Inseriti fuori ordine: l'API deve ordinare per created_at.
|
||||
rows = [
|
||||
TaskResult(task_id=note["id"], version_id=version.id, production_run_id=run.id,
|
||||
user_id=tec_user.id, outcome="done", note="ok finito",
|
||||
values={"peso": "12", "foto": "x.jpg"},
|
||||
attachments=[{"key": "foto", "path": "task_results/a.jpg", "name": "a.jpg"}],
|
||||
created_at=datetime(2026, 1, 1, 10, 3)),
|
||||
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
|
||||
user_id=tec_user.id, outcome="nok", note="graffio",
|
||||
created_at=datetime(2026, 1, 1, 10, 2)),
|
||||
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
|
||||
user_id=tec_user.id, outcome="ok",
|
||||
created_at=datetime(2026, 1, 1, 10, 1)),
|
||||
# Un esito di un'altra sessione (nessuna) non deve comparire.
|
||||
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=None,
|
||||
user_id=tec_user.id, outcome="ok",
|
||||
created_at=datetime(2026, 1, 1, 9, 0)),
|
||||
]
|
||||
db_session.add_all(rows)
|
||||
await db_session.commit()
|
||||
return recipe, run, check, note
|
||||
|
||||
async def test_counts_order_labels_attachments(
|
||||
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
_, run, check, note = await self._setup(
|
||||
client, db_session, maker_user, measurement_tec_user)
|
||||
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
|
||||
headers=auth_headers(metrologist_user))
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["run_id"] == run.id
|
||||
assert data["counts"] == {"ok": 1, "nok": 1, "done": 1}
|
||||
res = data["results"]
|
||||
assert [r["outcome"] for r in res] == ["ok", "nok", "done"] # per ora crescente
|
||||
assert res[1]["note"] == "graffio"
|
||||
assert res[1]["task_title"] == "Controllo"
|
||||
assert res[1]["task_type"] == "check"
|
||||
assert res[1]["user_name"] == measurement_tec_user.display_name
|
||||
assert res[1]["values"] == [] and res[1]["attachments"] == []
|
||||
n = res[2]
|
||||
assert n["task_title"] == "Nota" and n["note"] == "ok finito"
|
||||
assert n["values"] == [
|
||||
{"key": "peso", "label": "Peso", "value": "12"},
|
||||
{"key": "foto", "label": "Foto", "value": "x.jpg"},
|
||||
]
|
||||
assert n["attachments"] == [
|
||||
{"key": "foto", "label": "Foto", "name": "a.jpg", "path": "task_results/a.jpg"},
|
||||
]
|
||||
|
||||
async def test_label_falls_back_to_key_when_field_removed(
|
||||
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
_, run, _, note = await self._setup(
|
||||
client, db_session, maker_user, measurement_tec_user)
|
||||
task = await db_session.get(RecipeTask, note["id"])
|
||||
task.fields = [self.FIELDS[0]] # il campo «foto» viene tolto dal task
|
||||
await db_session.commit()
|
||||
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
|
||||
headers=auth_headers(metrologist_user))
|
||||
n = resp.json()["results"][2]
|
||||
assert {v["key"]: v["label"] for v in n["values"]} == {"peso": "Peso", "foto": "foto"}
|
||||
assert n["attachments"][0]["label"] == "foto"
|
||||
|
||||
async def test_unknown_run_is_404(self, client, metrologist_user):
|
||||
resp = await client.get("/api/statistics/runs/99999/task-results",
|
||||
headers=auth_headers(metrologist_user))
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_measurement_tec_is_403(self, client, measurement_tec_user):
|
||||
resp = await client.get("/api/statistics/runs/1/task-results",
|
||||
headers=auth_headers(measurement_tec_user))
|
||||
assert resp.status_code == 403
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""#45: il worker di visione si sceglie per stazione.
|
||||
|
||||
La stazione si legge dalla sessione di produzione (`production_run_id`): con un
|
||||
URL sulla stazione `_call_worker` lo riceve e il VisionResult lo registra;
|
||||
senza URL o senza run si resta sul worker centrale, senza ripiego fra i due.
|
||||
"""
|
||||
import io
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from PIL import Image
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.api.production import ProductionRunCreate
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
from src.backend.services import production_service
|
||||
from src.backend.services.vision_service import _call_worker
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.tests.test_vision_execute import recipe_parts
|
||||
from sqlalchemy import select
|
||||
|
||||
STATION_URL = "http://10.0.0.7:8100"
|
||||
|
||||
|
||||
def an_image() -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_calls(monkeypatch):
|
||||
"""Fake `_call_worker` che ricorda i kwargs con cui e' stato chiamato."""
|
||||
calls: list[dict] = []
|
||||
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
calls.append(kwargs)
|
||||
return {"outputs": {"t1.diameter": 10.0}, "failures": [],
|
||||
"engine_version": "e" * 40, "duration_ms": 3}
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
return calls
|
||||
|
||||
|
||||
async def _camera_task(db_session, user_id: int):
|
||||
recipe = await create_test_recipe(db_session, user_id)
|
||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||
task.task_type = "camera_measure"
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
subtask.vision_output = "t1.diameter"
|
||||
await db_session.commit()
|
||||
return recipe, task
|
||||
|
||||
|
||||
async def _station_with_run(db_session, user, recipe, worker_url):
|
||||
station = Station(
|
||||
code="ST-W", name="Stazione W", active=True, created_by=user.id,
|
||||
vision_worker_url=worker_url,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(station)
|
||||
run = await production_service.open_run(
|
||||
db_session,
|
||||
ProductionRunCreate(station_code=station.code, recipe_id=recipe.id),
|
||||
user,
|
||||
)
|
||||
await db_session.commit()
|
||||
return station, run
|
||||
|
||||
|
||||
async def _execute(client, user, task, run=None):
|
||||
data = {"task_id": str(task.id)}
|
||||
if run is not None:
|
||||
data["production_run_id"] = str(run.id)
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data=data,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
async def _the_result(db_session) -> VisionResult:
|
||||
return (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_station_with_url_gets_the_call_and_the_record(
|
||||
client, db_session, measurement_tec_user, worker_calls,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
station, run = await _station_with_run(
|
||||
db_session, measurement_tec_user, recipe, STATION_URL)
|
||||
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
|
||||
assert worker_calls[0]["worker_url"] == STATION_URL
|
||||
result = await _the_result(db_session)
|
||||
assert result.executed_on == "station"
|
||||
assert result.worker_url == STATION_URL
|
||||
assert result.station_id == station.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_station_without_url_uses_the_central_worker(
|
||||
client, db_session, measurement_tec_user, worker_calls,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
station, run = await _station_with_run(
|
||||
db_session, measurement_tec_user, recipe, None)
|
||||
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
|
||||
assert worker_calls[0]["worker_url"] is None
|
||||
result = await _the_result(db_session)
|
||||
assert result.executed_on == "server"
|
||||
assert result.worker_url == settings.vision_worker_url.rstrip("/")
|
||||
assert result.station_id == station.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_without_a_run_the_central_worker_is_used(
|
||||
client, db_session, measurement_tec_user, worker_calls,
|
||||
):
|
||||
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
|
||||
await _execute(client, measurement_tec_user, task)
|
||||
|
||||
assert worker_calls[0]["worker_url"] is None
|
||||
result = await _the_result(db_session)
|
||||
assert result.executed_on == "server"
|
||||
assert result.worker_url == settings.vision_worker_url.rstrip("/")
|
||||
assert result.station_id is None
|
||||
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
return {"outputs": {}, "failures": [], "engine_version": "e" * 40}
|
||||
|
||||
|
||||
class _RecordingClient:
|
||||
"""httpx.AsyncClient finto: registra gli URL postati, opzionalmente fallisce."""
|
||||
|
||||
def __init__(self, posts: list, raises=None):
|
||||
self._posts = posts
|
||||
self._raises = raises
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
async def post(self, url, *args, **kwargs):
|
||||
self._posts.append(url)
|
||||
if self._raises is not None:
|
||||
raise self._raises
|
||||
return _Response()
|
||||
|
||||
|
||||
def _install(monkeypatch, raises=None) -> list:
|
||||
posts: list = []
|
||||
monkeypatch.setattr(
|
||||
"src.backend.services.vision_service.httpx.AsyncClient",
|
||||
lambda *a, **k: _RecordingClient(posts, raises),
|
||||
)
|
||||
return posts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_worker_posts_to_the_given_url(monkeypatch):
|
||||
posts = _install(monkeypatch)
|
||||
# lo "/" finale non deve produrre "//run"
|
||||
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL + "/")
|
||||
assert posts == [f"{STATION_URL}/run"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_worker_defaults_to_the_central_url(monkeypatch):
|
||||
posts = _install(monkeypatch)
|
||||
await _call_worker(b"img", {"tools": []})
|
||||
assert posts == [f"{settings.vision_worker_url.rstrip('/')}/run"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_station_worker_down_is_502_with_its_url_and_no_fallback(monkeypatch):
|
||||
posts = _install(monkeypatch, raises=httpx.ConnectError("refused"))
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL)
|
||||
assert exc.value.status_code == 502
|
||||
assert STATION_URL in exc.value.detail
|
||||
# una sola POST: il centrale non viene tentato
|
||||
assert posts == [f"{STATION_URL}/run"]
|
||||
@@ -242,6 +242,9 @@ def create_app() -> Flask:
|
||||
"company_logo": session.get("company_logo"),
|
||||
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
||||
"current_year": date.today().year,
|
||||
# Versione/commit nel piede di pagina (#70)
|
||||
"app_version": Config.APP_VERSION,
|
||||
"app_commit": Config.APP_COMMIT,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
@@ -345,6 +345,7 @@ def task_execute(task_id: int):
|
||||
all_task_ids=all_task_ids,
|
||||
measurement_interval_minutes=measurement_interval_minutes,
|
||||
allow_manual_input=allow_manual_input,
|
||||
camera_enabled=_station_camera(_current_station()[0]) is not None,
|
||||
)
|
||||
|
||||
|
||||
@@ -1000,6 +1001,64 @@ def api_vision_execute():
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||
"""(camera_url, camera_source) of the station, None when it has no camera.
|
||||
|
||||
A station that cannot be read counts as "no camera": the grab button simply
|
||||
does not show, the file input keeps working.
|
||||
"""
|
||||
if not station_code:
|
||||
return None
|
||||
station = api_client.get_station_by_code(station_code)
|
||||
if not isinstance(station, dict) or station.get("error"):
|
||||
return None
|
||||
url = station.get("camera_url")
|
||||
source = station.get("camera_source")
|
||||
if not url or not source:
|
||||
return None
|
||||
return str(url), str(source)
|
||||
|
||||
|
||||
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
|
||||
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
|
||||
@measure_bp.route("/api/vision/grab", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_grab():
|
||||
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
|
||||
camera = _station_camera(_current_station()[0])
|
||||
if camera is None:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Camera non configurata per questa stazione"),
|
||||
}), 409
|
||||
camera_url, camera_source = camera
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{camera_url.rstrip('/')}/camera/grab",
|
||||
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
||||
timeout=15,
|
||||
)
|
||||
except http_requests.RequestException as exc:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
||||
}), 502
|
||||
if resp.status_code != 200:
|
||||
detail = ""
|
||||
try:
|
||||
body = resp.json()
|
||||
detail = body.get("detail", "") if isinstance(body, dict) else ""
|
||||
except ValueError:
|
||||
detail = (resp.text or "")[:200]
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Acquisizione non riuscita (%(status)s): %(detail)s",
|
||||
status=resp.status_code, detail=detail),
|
||||
}), 502
|
||||
return Response(resp.content, content_type="image/png")
|
||||
|
||||
|
||||
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request, session
|
||||
from flask import Blueprint, abort, jsonify, render_template, request, session
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
from services.file_proxy import proxy_file # #58: allegati dei task
|
||||
|
||||
statistics_bp = Blueprint("statistics", __name__)
|
||||
|
||||
@@ -148,6 +149,33 @@ def api_runs():
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/runs/<int:run_id>/task-results")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_run_task_results(run_id: int):
|
||||
"""Proxy: esiti, note, campi e allegati dei task di una sessione (#58)."""
|
||||
resp = api_client.get(f"/api/statistics/runs/{run_id}/task-results")
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
error_body = {"error": True, "detail": resp.get("detail", "")}
|
||||
if resp.get("session_expired"):
|
||||
error_body["session_expired"] = True
|
||||
return jsonify(error_body), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/files/<path:file_path>")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_task_result_file(file_path: str):
|
||||
"""Proxy: allegati dei task (#58). Solo task_results/, mai altri file."""
|
||||
# #58: il Metrologist non deve poter leggere altro dell'upload dir (es. statistics/).
|
||||
if not file_path.startswith("task_results/") or ".." in file_path.split("/"):
|
||||
abort(404)
|
||||
return proxy_file(file_path)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PROXY REPORT PDF → FastAPI
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""TieMeasureFlow Client Configuration."""
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||
@@ -10,6 +12,25 @@ load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
|
||||
|
||||
|
||||
def _read_app_version() -> str:
|
||||
"""Versione del prodotto = `[project] version` di pyproject.toml (#70).
|
||||
|
||||
Si risale dalla cartella del modulo: nel container il file sta in
|
||||
/app/pyproject.toml (Dockerfile.frontend), nel repo alla radice. Se non
|
||||
si trova o e' illeggibile torna "dev": il piede di pagina non deve mai
|
||||
rompere l'avvio.
|
||||
"""
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "pyproject.toml"
|
||||
if candidate.is_file():
|
||||
try:
|
||||
with candidate.open("rb") as fh:
|
||||
return str(tomllib.load(fh)["project"]["version"])
|
||||
except (OSError, tomllib.TOMLDecodeError, KeyError):
|
||||
return "dev"
|
||||
return "dev"
|
||||
|
||||
|
||||
class Config:
|
||||
"""Flask client configuration."""
|
||||
|
||||
@@ -17,6 +38,12 @@ class Config:
|
||||
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
|
||||
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
||||
|
||||
# Versione e commit mostrati nel piede di pagina (#70). Letti una volta
|
||||
# all'import. Il commit arriva dal build arg APP_COMMIT (.git non e' nel
|
||||
# contesto di build, vedi .dockerignore); facoltativo: vuoto = non mostrato.
|
||||
APP_VERSION: str = _read_app_version()
|
||||
APP_COMMIT: str = os.environ.get("APP_COMMIT", "")[:7]
|
||||
|
||||
# API Server connection
|
||||
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
||||
|
||||
|
||||
@@ -193,5 +193,9 @@ class APIClient:
|
||||
"""Return the list of active recipes assigned to the given station."""
|
||||
return self.get(f"/api/stations/by-code/{station_code}/recipes")
|
||||
|
||||
def get_station_by_code(self, station_code: str) -> dict[str, Any]:
|
||||
"""Return the station with the given code (camera_url / camera_source included)."""
|
||||
return self.get(f"/api/stations/by-code/{station_code}")
|
||||
|
||||
|
||||
api_client = APIClient()
|
||||
|
||||
@@ -18,6 +18,7 @@ function visionExecute() {
|
||||
|
||||
return {
|
||||
busy: false,
|
||||
grabbing: false, // frame requested from the station camera (#72)
|
||||
error: '',
|
||||
result: null, // last execute response
|
||||
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||
@@ -61,6 +62,34 @@ function visionExecute() {
|
||||
}
|
||||
},
|
||||
|
||||
// Station camera (#72): the Flask proxy answers a PNG, which goes through
|
||||
// acquire() like a picked file. Errors show as the execute ones do.
|
||||
async grab() {
|
||||
const cfg = window.__visionCfg || {};
|
||||
if (this.busy || this.grabbing || !cfg.grabUrl) return;
|
||||
this.grabbing = true;
|
||||
this.error = '';
|
||||
let blob = null;
|
||||
try {
|
||||
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const resp = await fetch(cfg.grabUrl, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
}
|
||||
blob = await resp.blob();
|
||||
} catch (e) {
|
||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
} finally {
|
||||
this.grabbing = false;
|
||||
}
|
||||
if (blob) await this.acquire(blob);
|
||||
},
|
||||
|
||||
// Input change: take the file and allow picking the same one again.
|
||||
onPick(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
|
||||
@@ -204,6 +204,37 @@
|
||||
maxlength="255">
|
||||
</div>
|
||||
|
||||
{# Worker visione (#45): vuoto = worker centrale #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Worker visione (URL)') }}</label>
|
||||
<input type="text" x-model="form.vision_worker_url"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="host:8100"
|
||||
maxlength="500">
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}</p>
|
||||
</div>
|
||||
|
||||
{# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Camera (URL)') }}</label>
|
||||
<input type="text" x-model="form.camera_url"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="host:8765"
|
||||
maxlength="500">
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label>
|
||||
<input type="text" x-model="form.camera_source"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="camera:folder"
|
||||
maxlength="200">
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Es. camera:<seriale> oppure camera:folder') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
|
||||
<textarea x-model="form.notes" rows="3"
|
||||
@@ -452,6 +483,9 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
name: '',
|
||||
location: '',
|
||||
notes: '',
|
||||
vision_worker_url: '', // #45
|
||||
camera_url: '', // #72
|
||||
camera_source: '', // #72
|
||||
active: true,
|
||||
},
|
||||
|
||||
@@ -505,7 +539,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
this.isEditing = false;
|
||||
this.editingId = null;
|
||||
this.errorMsg = '';
|
||||
this.form = { code: '', name: '', location: '', notes: '', active: true };
|
||||
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', camera_url: '', camera_source: '', active: true };
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
@@ -518,6 +552,9 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
name: station.name,
|
||||
location: station.location || '',
|
||||
notes: station.notes || '',
|
||||
vision_worker_url: station.vision_worker_url || '', // #45
|
||||
camera_url: station.camera_url || '', // #72
|
||||
camera_source: station.camera_source || '', // #72
|
||||
active: station.active,
|
||||
};
|
||||
this.showModal = true;
|
||||
@@ -537,6 +574,9 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
name: this.form.name,
|
||||
location: this.form.location || null,
|
||||
notes: this.form.notes || null,
|
||||
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
||||
camera_url: (this.form.camera_url || '').trim() || null, // #72
|
||||
camera_source: (this.form.camera_source || '').trim() || null, // #72
|
||||
active: this.form.active,
|
||||
};
|
||||
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
|
||||
@@ -563,6 +603,9 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
name: this.form.name,
|
||||
location: this.form.location || null,
|
||||
notes: this.form.notes || null,
|
||||
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
||||
camera_url: (this.form.camera_url || '').trim() || null, // #72
|
||||
camera_source: (this.form.camera_source || '').trim() || null, // #72
|
||||
active: this.form.active,
|
||||
};
|
||||
const resp = await fetch('/admin/api/stations', {
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center text-xs text-[var(--text-muted)]">
|
||||
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
|
||||
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}{# versione e commit (#70) #} · v{{ app_version }}{% if app_commit %} · {{ app_commit }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<!-- Footer -->
|
||||
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
|
||||
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
|
||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic
|
||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic{# versione e commit (#70) #} · v{{ app_version }}{% if app_commit %} · {{ app_commit }}{% endif %}
|
||||
</p>
|
||||
</footer>
|
||||
{% endblock %}
|
||||
|
||||
@@ -544,6 +544,15 @@
|
||||
<input type="file" accept="image/*" capture="environment" class="hidden"
|
||||
@change="onPick($event)" :disabled="busy">
|
||||
</label>
|
||||
{% if camera_enabled %}
|
||||
{# #72: frame dalla camera della stazione, stessa strada di acquire() #}
|
||||
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
|
||||
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
||||
:disabled="busy || grabbing">
|
||||
<span>{{ _('Acquisisci dalla camera') }}</span>
|
||||
</button>
|
||||
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
|
||||
{% endif %}
|
||||
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1136,6 +1145,7 @@
|
||||
<script>
|
||||
window.__visionCfg = {
|
||||
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
||||
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
|
||||
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||
taskId: {{ task.id|tojson }},
|
||||
taskType: {{ (task.task_type or '')|tojson }},
|
||||
|
||||
@@ -129,6 +129,74 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #58: esiti, note, campi e allegati dei task della sessione scelta -->
|
||||
<div id="run-task-results" x-show="selectedRunId" x-cloak
|
||||
class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 mb-6 shadow-sm">
|
||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Esiti dei task della sessione") }}</h2>
|
||||
<p x-show="taskResultsLoading" class="text-sm text-steel">{{ _("Caricamento...") }}</p>
|
||||
<p x-show="taskResultsError" x-text="taskResultsError" class="text-sm text-red-600"></p>
|
||||
<template x-if="taskResults && !taskResultsLoading">
|
||||
<div>
|
||||
<div class="grid grid-cols-3 gap-3 mb-4 max-w-md">
|
||||
<div class="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
|
||||
<div class="text-2xl font-bold font-mono text-emerald-600" x-text="taskResults.counts.ok"></div>
|
||||
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("OK") }}</div>
|
||||
</div>
|
||||
<div class="text-center p-3 rounded-lg bg-red-50 dark:bg-red-900/20">
|
||||
<div class="text-2xl font-bold font-mono text-red-600" x-text="taskResults.counts.nok"></div>
|
||||
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("NOK") }}</div>
|
||||
</div>
|
||||
<div class="text-center p-3 rounded-lg bg-[var(--bg-primary)]">
|
||||
<div class="text-2xl font-bold font-mono text-[var(--text-primary)]" x-text="taskResults.counts.done"></div>
|
||||
<div class="text-xs text-steel mt-1">{{ _("Fatto") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="taskResults.results.length === 0" class="text-sm text-steel">{{ _("Nessun esito registrato in questa sessione") }}</p>
|
||||
<div x-show="taskResults.results.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-steel uppercase border-b border-[var(--border-color)]">
|
||||
<th class="py-2 pr-3">{{ _("Ora") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Task") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Esito") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Note") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Campi") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Allegati") }}</th>
|
||||
<th class="py-2">{{ _("Operatore") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="r in taskResults.results" :key="r.id">
|
||||
<tr class="border-b border-[var(--border-color)] align-top">
|
||||
<td class="py-2 pr-3 font-mono whitespace-nowrap" x-text="resultTime(r.created_at)"></td>
|
||||
<td class="py-2 pr-3" x-text="r.task_title"></td>
|
||||
<td class="py-2 pr-3">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-semibold"
|
||||
:class="r.outcome === 'nok' ? 'bg-red-600 text-white' : (r.outcome === 'ok' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-700')"
|
||||
x-text="outcomeLabel(r.outcome)"></span>
|
||||
</td>
|
||||
<td class="py-2 pr-3 whitespace-pre-line" x-text="r.note"></td>
|
||||
<td class="py-2 pr-3">
|
||||
<template x-for="v in r.values" :key="v.key">
|
||||
<div><span class="text-steel" x-text="v.label + ': '"></span><span x-text="v.value"></span></div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="py-2 pr-3">
|
||||
<template x-for="a in r.attachments" :key="a.path">
|
||||
<div><a :href="attachmentUrl(a.path)" target="_blank" rel="noopener"
|
||||
class="text-primary hover:underline" x-text="a.name || a.label"></a></div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="py-2" x-text="r.user_name"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Nessuna ricetta selezionata -->
|
||||
<template x-if="!selectedRecipeId && !loading">
|
||||
<div class="text-center py-16 text-steel">
|
||||
@@ -290,8 +358,52 @@ function spcDashboard() {
|
||||
reportError: '',
|
||||
downloadingReport: false,
|
||||
|
||||
// #58: esiti dei task della sessione scelta
|
||||
taskResults: null,
|
||||
taskResultsLoading: false,
|
||||
taskResultsError: '',
|
||||
|
||||
init() {
|
||||
// Nothing to pre-load
|
||||
// #58: il pannello esiti si ricarica a ogni cambio di sessione
|
||||
this.$watch('selectedRunId', () => this.loadTaskResults());
|
||||
},
|
||||
|
||||
async loadTaskResults() {
|
||||
this.taskResults = null;
|
||||
this.taskResultsError = '';
|
||||
const runId = this.selectedRunId;
|
||||
if (!runId) return;
|
||||
this.taskResultsLoading = true;
|
||||
try {
|
||||
const resp = await fetch('/statistics/api/runs/' + runId + '/task-results');
|
||||
const data = await resp.json();
|
||||
// #58: se nel frattempo la sessione e' cambiata, il risultato e' vecchio
|
||||
if (runId !== this.selectedRunId) return;
|
||||
if (!resp.ok || data.error) {
|
||||
this.taskResultsError = data.detail || {{ _("Errore nel caricamento dei dati")|tojson }};
|
||||
} else {
|
||||
this.taskResults = data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading task results:', e);
|
||||
this.taskResultsError = {{ _("Errore di connessione al server")|tojson }};
|
||||
} finally {
|
||||
if (runId === this.selectedRunId) this.taskResultsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// #58: ora locale dell'esito (created_at e' UTC senza suffisso)
|
||||
resultTime(iso) {
|
||||
return iso ? new Date(iso + (iso.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
|
||||
},
|
||||
|
||||
outcomeLabel(o) {
|
||||
return o === 'ok' ? {{ _("OK")|tojson }} : (o === 'nok' ? {{ _("NOK")|tojson }} : {{ _("Fatto")|tojson }});
|
||||
},
|
||||
|
||||
// #58: gli allegati passano dal proxy del blueprint, il browser non manda X-API-Key
|
||||
attachmentUrl(path) {
|
||||
return '/statistics/api/files/' + String(path).split('/').map(encodeURIComponent).join('/');
|
||||
},
|
||||
|
||||
async onRecipeChange() {
|
||||
|
||||
@@ -176,3 +176,81 @@ def test_station_list_page_renders_assigned_recipes(logged_in_client, mock_admin
|
||||
resp = logged_in_client.get("/admin/stations")
|
||||
assert resp.status_code == 200
|
||||
assert b"REC-ABC" in resp.data
|
||||
|
||||
|
||||
# --- Worker visione per stazione (#45) ---
|
||||
|
||||
def test_station_form_has_the_vision_worker_field(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.get.return_value = []
|
||||
resp = logged_in_client.get("/admin/stations")
|
||||
assert resp.status_code == 200
|
||||
assert b"form.vision_worker_url" in resp.data
|
||||
# etichetta in italiano o in inglese, secondo il catalogo compilato
|
||||
assert b"Worker visione (URL)" in resp.data or b"Vision worker (URL)" in resp.data
|
||||
|
||||
|
||||
def test_station_list_exposes_the_vision_worker_url(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.get.side_effect = [
|
||||
[{"id": 1, "code": "ST-001", "name": "Linea A", "location": None,
|
||||
"notes": None, "active": True, "vision_worker_url": "http://10.0.0.7:8100",
|
||||
"created_by": 1, "created_at": "2026-04-25T10:00:00"}],
|
||||
[],
|
||||
]
|
||||
resp = logged_in_client.get("/admin/stations")
|
||||
assert b"http://10.0.0.7:8100" in resp.data # nel JSON iniziale della pagina
|
||||
|
||||
|
||||
def test_station_proxies_pass_the_vision_worker_url_untouched(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.post.return_value = {"id": 7, "code": "ST-7", "name": "n"}
|
||||
mock_admin_api.put.return_value = {"id": 7, "code": "ST-7", "name": "n"}
|
||||
logged_in_client.post(
|
||||
"/admin/api/stations",
|
||||
json={"code": "ST-7", "name": "n", "vision_worker_url": "http://w:8100"},
|
||||
)
|
||||
mock_admin_api.post.assert_called_once_with(
|
||||
"/api/stations",
|
||||
data={"code": "ST-7", "name": "n", "vision_worker_url": "http://w:8100"},
|
||||
)
|
||||
logged_in_client.put("/admin/api/stations/7", json={"vision_worker_url": None})
|
||||
mock_admin_api.put.assert_called_once_with(
|
||||
"/api/stations/7", data={"vision_worker_url": None},
|
||||
)
|
||||
|
||||
|
||||
# --- Camera per stazione (#72) ---
|
||||
|
||||
def test_station_form_has_the_camera_fields(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.get.return_value = []
|
||||
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||
assert "form.camera_url" in html
|
||||
assert "form.camera_source" in html
|
||||
|
||||
|
||||
def test_station_list_exposes_the_camera_fields(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.get.side_effect = [
|
||||
[{"id": 1, "code": "ST-001", "name": "Linea A", "location": None,
|
||||
"notes": None, "active": True,
|
||||
"camera_url": "http://10.0.0.9:8765", "camera_source": "camera:folder",
|
||||
"created_by": 1, "created_at": "2026-04-25T10:00:00"}],
|
||||
[],
|
||||
]
|
||||
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||
assert "http://10.0.0.9:8765" in html
|
||||
assert "camera:folder" in html
|
||||
|
||||
|
||||
def test_station_payload_script_sends_the_camera_fields(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.get.return_value = []
|
||||
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||
# default, reset, carica, crea, aggiorna: ogni punto che già porta vision_worker_url
|
||||
assert html.count("camera_url") >= html.count("vision_worker_url")
|
||||
assert html.count("camera_source") >= html.count("vision_worker_url")
|
||||
|
||||
|
||||
def test_create_station_proxy_forwards_the_camera_fields(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.post.return_value = {"id": 5, "code": "ST-005", "name": "N"}
|
||||
body = {"code": "ST-005", "name": "N",
|
||||
"camera_url": "http://h:8765", "camera_source": "camera:folder"}
|
||||
resp = logged_in_client.post("/admin/api/stations", json=body)
|
||||
assert resp.status_code == 201
|
||||
mock_admin_api.post.assert_called_once_with("/api/stations", data=body)
|
||||
|
||||
@@ -13,3 +13,12 @@ def test_get_station_recipes_calls_correct_endpoint():
|
||||
called_args, called_kwargs = mock_get.call_args
|
||||
assert called_args[0] == "/api/stations/by-code/ST-001/recipes"
|
||||
assert result == [{"id": 1, "code": "R1", "name": "R1", "active": True}]
|
||||
|
||||
|
||||
def test_get_station_by_code_calls_correct_endpoint():
|
||||
client = APIClient()
|
||||
with patch.object(client, "get") as mock_get:
|
||||
mock_get.return_value = {"id": 1, "code": "ST-001", "camera_url": None}
|
||||
result = client.get_station_by_code("ST-001")
|
||||
assert mock_get.call_args[0][0] == "/api/stations/by-code/ST-001"
|
||||
assert result["code"] == "ST-001"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Versione e commit nel piede di pagina (#70)."""
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from config import Config
|
||||
|
||||
_PYPROJECT = Path(__file__).resolve().parents[4] / "pyproject.toml"
|
||||
|
||||
|
||||
def _pyproject_version() -> str:
|
||||
with _PYPROJECT.open("rb") as fh:
|
||||
return tomllib.load(fh)["project"]["version"]
|
||||
|
||||
|
||||
def test_app_version_matches_pyproject():
|
||||
# La versione mostrata e' quella di pyproject.toml, non un valore a mano
|
||||
assert Config.APP_VERSION == _pyproject_version()
|
||||
|
||||
|
||||
def test_login_page_shows_version(client):
|
||||
resp = client.get("/auth/login")
|
||||
assert resp.status_code == 200
|
||||
assert f"v{_pyproject_version()}" in resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_login_page_shows_commit_when_set(client, monkeypatch):
|
||||
# Commit valorizzato (gia' troncato a 7 da Config): compare nel footer
|
||||
monkeypatch.setattr(Config, "APP_COMMIT", "abc1234")
|
||||
assert "abc1234" in client.get("/auth/login").get_data(as_text=True)
|
||||
|
||||
|
||||
def test_login_page_hides_commit_when_empty(client, monkeypatch):
|
||||
# Build senza APP_COMMIT: solo versione, nessun separatore orfano
|
||||
monkeypatch.setattr(Config, "APP_COMMIT", "")
|
||||
html = client.get("/auth/login").get_data(as_text=True)
|
||||
assert f"v{_pyproject_version()}" in html
|
||||
assert f"v{_pyproject_version()} ·" not in html
|
||||
@@ -0,0 +1,161 @@
|
||||
"""gitea:Adriano/TieMeasureFlow#72 - frame grab from the station camera.
|
||||
|
||||
The backend (station.camera_url / camera_source, GET /api/stations/by-code/{code})
|
||||
is built elsewhere: api_client and requests are mocked here.
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from tests.conftest import api_get_router
|
||||
|
||||
STATION = {
|
||||
"id": 1, "code": "ST-TEST", "name": "Linea",
|
||||
"camera_url": "http://host.docker.internal:8765",
|
||||
"camera_source": "camera:folder",
|
||||
}
|
||||
PNG = b"\x89PNG\r\n\x1a\n fake"
|
||||
|
||||
|
||||
def _fake(status=200, content=PNG, ctype="image/png", json_body=None):
|
||||
resp = MagicMock(status_code=status, content=content, text=content.decode("latin-1"))
|
||||
resp.headers = {"content-type": ctype}
|
||||
if json_body is None:
|
||||
resp.json.side_effect = ValueError("no json")
|
||||
else:
|
||||
resp.json.return_value = json_body
|
||||
return resp
|
||||
|
||||
|
||||
def _station(mock_api_client, monkeypatch, station):
|
||||
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||
mock_api_client.get_station_by_code.return_value = station
|
||||
|
||||
|
||||
def test_grab_without_camera_is_409(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
|
||||
with patch("blueprints.measure.http_requests.post") as post:
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 409
|
||||
assert resp.get_json()["error"]
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_grab_without_source_is_409(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, {**STATION, "camera_source": None})
|
||||
with patch("blueprints.measure.http_requests.post") as post:
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 409
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_grab_station_lookup_error_is_409(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, {"error": True, "status_code": 404, "detail": "x"})
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_grab_passes_png_through(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 200
|
||||
assert resp.mimetype == "image/png"
|
||||
assert resp.data == PNG
|
||||
mock_api_client.get_station_by_code.assert_called_once_with("ST-TEST")
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == "http://host.docker.internal:8765/camera/grab"
|
||||
assert kwargs["json"] == {
|
||||
"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000,
|
||||
}
|
||||
assert kwargs["timeout"] == 15
|
||||
|
||||
|
||||
def test_grab_service_503_is_502(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
fake = _fake(503, b"{}", "application/json", {"detail": "camera occupata"})
|
||||
with patch("blueprints.measure.http_requests.post", return_value=fake):
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 502
|
||||
body = resp.get_json()
|
||||
assert body["error"] is True
|
||||
assert "camera occupata" in body["detail"]
|
||||
|
||||
|
||||
def test_grab_service_504_and_404_are_502(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
for status in (404, 504):
|
||||
with patch("blueprints.measure.http_requests.post",
|
||||
return_value=_fake(status, b"nope", "text/plain")):
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 502
|
||||
assert resp.get_json()["error"] is True
|
||||
|
||||
|
||||
def test_grab_connection_error_is_502(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
with patch("blueprints.measure.http_requests.post",
|
||||
side_effect=requests.ConnectionError("refused")):
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 502
|
||||
assert resp.get_json()["error"] is True
|
||||
|
||||
|
||||
def test_grab_requires_measurement_tec(flask_app, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
with flask_app.test_client() as c:
|
||||
with c.session_transaction() as sess:
|
||||
sess["api_key"] = "k"
|
||||
sess["user"] = {"id": 2, "username": "m", "roles": ["Maker"],
|
||||
"is_admin": False, "active": True}
|
||||
sess["user_id"] = 2
|
||||
with patch("blueprints.measure.http_requests.post") as post:
|
||||
resp = c.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 403
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_grab_requires_login(client):
|
||||
resp = client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code in (302, 401)
|
||||
|
||||
|
||||
# --- execute page: button only when the station has a camera -----------------
|
||||
TASK = {
|
||||
"id": 12, "recipe_id": 3, "version_id": 5, "task_type": "camera_measure",
|
||||
"title": "Foto", "directive": None, "description": None, "file_path": None,
|
||||
"file_type": None, "annotations_json": None, "subtasks": [],
|
||||
}
|
||||
RECIPE = {
|
||||
"id": 3, "code": "REC-1", "name": "Ricetta",
|
||||
"measurement_interval_minutes": 30, "allow_manual_input": True,
|
||||
"current_version": {"id": 5, "version_number": 1},
|
||||
}
|
||||
|
||||
|
||||
def _page(logged_in_client, mock_api_client, monkeypatch, station):
|
||||
_station(mock_api_client, monkeypatch, station)
|
||||
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/tasks/12": TASK,
|
||||
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
|
||||
"/api/recipes/3": RECIPE,
|
||||
})
|
||||
return logged_in_client.get("/measure/execute/12").get_data(as_text=True)
|
||||
|
||||
|
||||
def test_execute_page_shows_grab_button_with_camera(logged_in_client, mock_api_client, monkeypatch):
|
||||
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION)
|
||||
assert "/measure/api/vision/grab" in html
|
||||
assert "grab()" in html
|
||||
|
||||
|
||||
def test_execute_page_hides_grab_button_without_camera(logged_in_client, mock_api_client, monkeypatch):
|
||||
html = _page(logged_in_client, mock_api_client, monkeypatch, {**STATION, "camera_url": None})
|
||||
assert "grab()" not in html
|
||||
|
||||
|
||||
def test_execute_page_hides_grab_button_when_lookup_fails(logged_in_client, mock_api_client, monkeypatch):
|
||||
html = _page(logged_in_client, mock_api_client, monkeypatch,
|
||||
{"error": True, "status_code": 502, "detail": "down"})
|
||||
assert "grab()" not in html
|
||||
@@ -130,3 +130,40 @@ class TestRunsProxy:
|
||||
mock_api_client.get.return_value = {"total": 1}
|
||||
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
|
||||
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
|
||||
|
||||
|
||||
class TestRunTaskResultsProxy:
|
||||
"""#58: esiti dei task e allegati della sessione."""
|
||||
|
||||
def test_task_results_proxy(self, logged_in_client, mock_api_client):
|
||||
payload = {"run_id": 5, "counts": {"ok": 1, "nok": 0, "done": 0}, "results": []}
|
||||
mock_api_client.get.return_value = payload
|
||||
resp = logged_in_client.get("/statistics/api/runs/5/task-results")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == payload
|
||||
assert mock_api_client.get.call_args.args[0] == "/api/statistics/runs/5/task-results"
|
||||
|
||||
def test_task_results_proxy_relays_error(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = {"error": True, "detail": "x", "status_code": 404}
|
||||
resp = logged_in_client.get("/statistics/api/runs/9/task-results")
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json()["error"] is True
|
||||
|
||||
def test_file_proxy_serves_task_results_only(self, logged_in_client):
|
||||
from unittest.mock import patch
|
||||
from flask import Response
|
||||
with patch("blueprints.statistics.proxy_file",
|
||||
return_value=Response("img", status=200)) as pf:
|
||||
ok = logged_in_client.get("/statistics/api/files/task_results/a.jpg")
|
||||
assert ok.status_code == 200
|
||||
pf.assert_called_once_with("task_results/a.jpg")
|
||||
for bad in ("statistics/x", "task_results/../statistics/x", "../etc/passwd"):
|
||||
assert logged_in_client.get(f"/statistics/api/files/{bad}").status_code == 404
|
||||
assert pf.call_count == 1 # i percorsi rifiutati non arrivano al proxy
|
||||
|
||||
def test_dashboard_has_task_results_panel(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
|
||||
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
|
||||
assert 'id="run-task-results"' in html
|
||||
assert "loadTaskResults" in html
|
||||
assert "/statistics/api/runs/" in html
|
||||
|
||||
@@ -754,6 +754,14 @@ msgstr "Code cannot be modified"
|
||||
msgid "Identificativo univoco usato dal client tramite STATION_CODE"
|
||||
msgstr "Unique identifier used by client via STATION_CODE"
|
||||
|
||||
#: templates/admin/stations.html
|
||||
msgid "Worker visione (URL)"
|
||||
msgstr "Vision worker (URL)"
|
||||
|
||||
#: templates/admin/stations.html
|
||||
msgid "Vuoto = worker centrale. Indirizzo completo, con http o https"
|
||||
msgstr "Empty = central worker. Full address, with http or https"
|
||||
|
||||
#: templates/admin/stations.html:194
|
||||
msgid "Nome descrittivo della stazione"
|
||||
msgstr "Descriptive name of the station"
|
||||
@@ -2969,6 +2977,36 @@ msgstr "Select a measurement point for the histogram"
|
||||
msgid "Errore nella generazione del report"
|
||||
msgstr "Error generating report"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Esiti dei task della sessione"
|
||||
msgstr "Task outcomes of the session"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Nessun esito registrato in questa sessione"
|
||||
msgstr "No outcome recorded in this session"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Fatto"
|
||||
msgstr "Done"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Ora"
|
||||
msgstr "Time"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Campi"
|
||||
msgstr "Fields"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Allegati"
|
||||
msgstr "Attachments"
|
||||
|
||||
#~ msgid "Esci"
|
||||
#~ msgstr "Logout"
|
||||
|
||||
@@ -3284,3 +3322,40 @@ msgstr "Error generating report"
|
||||
#~ msgid "Crea, modifica e gestisci gli utenti del sistema"
|
||||
#~ msgstr "Create, edit and manage system users"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Camera (URL)"
|
||||
msgstr "Camera (URL)"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale"
|
||||
msgstr "Empty = no camera. Camera service address, with http or https, no trailing /"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Sorgente camera"
|
||||
msgstr "Camera source"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Es. camera:<seriale> oppure camera:folder"
|
||||
msgstr "E.g. camera:<serial> or camera:folder"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Acquisisci dalla camera"
|
||||
msgstr "Capture from camera"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Acquisizione in corso..."
|
||||
msgstr "Capturing..."
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Camera non configurata per questa stazione"
|
||||
msgstr "Camera not configured for this station"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
#, python-format
|
||||
msgid "Servizio camera non raggiungibile: %(detail)s"
|
||||
msgstr "Camera service unreachable: %(detail)s"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
#, python-format
|
||||
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
|
||||
msgstr "Capture failed (%(status)s): %(detail)s"
|
||||
|
||||
@@ -756,6 +756,14 @@ msgstr "Il codice non può essere modificato"
|
||||
msgid "Identificativo univoco usato dal client tramite STATION_CODE"
|
||||
msgstr "Identificativo univoco usato dal client tramite STATION_CODE"
|
||||
|
||||
#: templates/admin/stations.html
|
||||
msgid "Worker visione (URL)"
|
||||
msgstr "Worker visione (URL)"
|
||||
|
||||
#: templates/admin/stations.html
|
||||
msgid "Vuoto = worker centrale. Indirizzo completo, con http o https"
|
||||
msgstr "Vuoto = worker centrale. Indirizzo completo, con http o https"
|
||||
|
||||
#: templates/admin/stations.html:194
|
||||
msgid "Nome descrittivo della stazione"
|
||||
msgstr "Nome descrittivo della stazione"
|
||||
@@ -2973,6 +2981,36 @@ msgstr "Seleziona un punto di misura per l'istogramma"
|
||||
msgid "Errore nella generazione del report"
|
||||
msgstr "Errore nella generazione del report"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Esiti dei task della sessione"
|
||||
msgstr "Esiti dei task della sessione"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Nessun esito registrato in questa sessione"
|
||||
msgstr "Nessun esito registrato in questa sessione"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Fatto"
|
||||
msgstr "Fatto"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Ora"
|
||||
msgstr "Ora"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Campi"
|
||||
msgstr "Campi"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Allegati"
|
||||
msgstr "Allegati"
|
||||
|
||||
#~ msgid "Esci"
|
||||
#~ msgstr "Esci"
|
||||
|
||||
@@ -3272,3 +3310,40 @@ msgstr "Errore nella generazione del report"
|
||||
#~ msgid "Crea, modifica e gestisci gli utenti del sistema"
|
||||
#~ msgstr "Crea, modifica e gestisci gli utenti del sistema"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Camera (URL)"
|
||||
msgstr "Camera (URL)"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale"
|
||||
msgstr "Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Sorgente camera"
|
||||
msgstr "Sorgente camera"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Es. camera:<seriale> oppure camera:folder"
|
||||
msgstr "Es. camera:<seriale> oppure camera:folder"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Acquisisci dalla camera"
|
||||
msgstr "Acquisisci dalla camera"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Acquisizione in corso..."
|
||||
msgstr "Acquisizione in corso..."
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Camera non configurata per questa stazione"
|
||||
msgstr "Camera non configurata per questa stazione"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
#, python-format
|
||||
msgid "Servizio camera non raggiungibile: %(detail)s"
|
||||
msgstr "Servizio camera non raggiungibile: %(detail)s"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
#, python-format
|
||||
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
|
||||
msgstr "Acquisizione non riuscita (%(status)s): %(detail)s"
|
||||
|
||||
@@ -783,6 +783,33 @@ woff = [
|
||||
{ name = "zopfli" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "genicam"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/39/97c9a39fde61441731a383bde6a86b079a5a9d1d22c6d9eb16556b77016e/genicam-1.6.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:2a5bfeb3b9796cf6667192c2957f8ea7ec240ff42e76cc6e3e05d1d0230bf68a", size = 2556729, upload-time = "2026-09-01T08:26:57.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/1b/bf993221a155d2fd35c8201be97cd0376e005bf1cc82dec27105a712d3b1/genicam-1.6.0-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:4207d7ccee9deb755ad372c0b473fd2f23e4e42da61e06e4a7873d199b883a0f", size = 2604157, upload-time = "2026-09-01T08:26:58.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/92/68c0033f076ad1defed2e6bf1cec8fc720a24622fb0389013b058e841234/genicam-1.6.0-cp311-cp311-manylinux2014_armv7l.whl", hash = "sha256:a750ffb11194f02740531bc0f62bf7013d39f2eea65f5e283c7811ab757fc987", size = 2190252, upload-time = "2026-09-01T08:27:00.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b3/252e5d64022984de4a78220c456f1f91742dbd7bb02d5b3926ed4c228015/genicam-1.6.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:589b1152f22ca766f8b5e1dff9c1b4006c2eb7f5ee33152619ef6ece8a5a3f47", size = 2800290, upload-time = "2026-09-01T08:27:01.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/ec/19092034d0f66646d97b85e1549b6b4e2448f5287bb3a76b5a8cd677ba4a/genicam-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:ef1d25c7e9823689dbfb9e08d8fdb46e0f05982ca9c27d1eeacc1aa8fcd69f6e", size = 1802065, upload-time = "2026-09-01T08:27:02.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/84/0dc939a435deef267d82bc6aaa4b7ed9c89510766454640622608d0e7ce5/genicam-1.6.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:d0becabb469d0f51f9d4820d655d2788c620a8ee1c8abcfa4703cac87ba27f53", size = 2557117, upload-time = "2026-09-01T08:27:03.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/68/499657bf3e5111fe1c87e3364c2ae6b59fe912d7184fccf591eafd762ceb/genicam-1.6.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:3a1a16b89fec69d52142781f280ce2c01a4c1422da1cd08893ed1cd630d11c30", size = 2607960, upload-time = "2026-09-01T08:27:05.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/ac/3b8496c446715008e77dda7439735c428f163aab0a3f643352407a259a52/genicam-1.6.0-cp312-cp312-manylinux2014_armv7l.whl", hash = "sha256:b65beca4b425442922a2f2beefae1e3109ce166cfd726fcd9d2ac59ff1d57e24", size = 2189851, upload-time = "2026-09-01T08:27:06.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/9c/49d68f5055e970e4e4d834bc3df686a01f7bd82e070888fc7ad2c86e2eaf/genicam-1.6.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:83da90404da189a87273864f2c97c761c1807fc6fc532fabd3bf50c0ae28f8ae", size = 2799503, upload-time = "2026-09-01T08:27:07.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/4a/e643cac4031ad7b1780ab19bcdeb6b0545e829b2ee10ea37ca1185d9417d/genicam-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d2bb3f39443c5bc248fcbde83a3a973370ca43588a7c1ded7d7324d7d9427d3", size = 1802200, upload-time = "2026-09-01T08:27:09.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/f1/b5f641ad64c296462ab2ca314b8a471827142b949606ebe8b69bff7e6a08/genicam-1.6.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:f5c35275014f4793b34a21ebe8bbcb269a87b5d1bf129daf2818c6160f8758c9", size = 2557108, upload-time = "2026-09-01T08:27:11.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/59/86ae5cbc1a89f70c2b3b906e692a46b977e9230664781cd5a304012f6b20/genicam-1.6.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:f4d6496660507b32a7f6ef05f5344bcd305ea3e2158a41bf53169663d7a72e70", size = 2608137, upload-time = "2026-09-01T08:27:12.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/37/25a84938b5ec34fc6123006a3f4173fead4fde3b460b516749a8ae8c1b02/genicam-1.6.0-cp313-cp313-manylinux2014_armv7l.whl", hash = "sha256:efe646df5e8ca76208c6621e8a5dbea3e9909641801787993ea2d7ec4a704c09", size = 2189839, upload-time = "2026-09-01T08:27:13.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/eb/37363beb1880674ff0976ff7607e2e045933c6388d08d98bc0b512df941c/genicam-1.6.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:a342980e80735b7b1e4f18f6c33d64eccc3e95887dd72f302409f1296bef3dac", size = 2799503, upload-time = "2026-09-01T08:27:15.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/38/895e9effffc858ff06996a9eed28decdc9b5b2176b2cfd82df8c4f65bdec/genicam-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:740156c5fa8d82330d36314112f4b755e8333c16f82792174f1a2a2e150519ae", size = 1802243, upload-time = "2026-09-01T08:27:16.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/26/98a864061d25e78b96b7d621814b399a78fb7603ae6a6ac871648587bc49/genicam-1.6.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:064a87188b6102bac03a8ab1ccff5280e8fa4cf6378448e6286ed6f4cc9f7a64", size = 2557508, upload-time = "2026-09-01T08:27:17.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/32/2ef1193d27bb375e3d9822be883e987d75203fcb417bafa96fd77fe8be18/genicam-1.6.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:3dcf4af1fabb1cee06cac0bed6560f0e49180dab1e078501949eaf07596736c3", size = 2609251, upload-time = "2026-09-01T08:27:19.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/06/ea8b6c2f4536cd8f54961040edae1ad9ff8a3dced2081a2377dac600bb73/genicam-1.6.0-cp314-cp314-manylinux2014_armv7l.whl", hash = "sha256:e3f9ff542bbd2fe389e1a94e4a694ec34f94ab378247c5f7e761b78ff5891d24", size = 2189734, upload-time = "2026-09-01T08:27:20.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/ff/a8c90b9a7d99fa6008437491f1198264c22d49572a0013a05bc1c579e034/genicam-1.6.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:d1e12e276eab6dbe8dec37e060ea5a8cc5758cc8c3502a048782e38a0451517d", size = 2799526, upload-time = "2026-09-01T08:27:21.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/18/c6f0b7f3da480aac0c86d104664e9cf144675986cc4e3f108c909bec0ddf/genicam-1.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:7630b7844b273080f21f01881c56667cc68521186b75dd15798f5dee1f8f1037", size = 1846842, upload-time = "2026-09-01T08:27:23.18Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.4.0"
|
||||
@@ -861,6 +888,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "harvesters"
|
||||
version = "1.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "genicam" },
|
||||
{ name = "numpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/18/ee45d896fee51dee2e35df9d371e2d213ed4488ddea4dd4bd1df6f92a5fb/harvesters-1.4.3.tar.gz", hash = "sha256:c237dd402735a66ebd570865113a3e80ae19161a21706aed80691627a8cb7da2", size = 104391, upload-time = "2024-05-16T17:09:41.805Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/ab/295e5ec52cc2859ad8fa0d56f953cefa26f3e1eb28cc937162d7df7df872/harvesters-1.4.3-py3-none-any.whl", hash = "sha256:74994cedc941051b9ce9527c5b336e611589a1bc209bc03186c49102a55f1f1f", size = 100253, upload-time = "2024-05-16T17:09:27.933Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
@@ -1113,6 +1153,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nuitka"
|
||||
version = "4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/5f/ba7cb858da0c98c24f85c54454a5ea18f2b784f658a91531fa051b2f2ee9/nuitka-4.3.tar.gz", hash = "sha256:8b102c6bf30d9504e82e69674d396972729092b25cefa680e8fd05678fdfa30b", size = 4766934, upload-time = "2026-10-10T11:13:15.577Z" }
|
||||
|
||||
[[package]]
|
||||
name = "numba"
|
||||
version = "0.67.0"
|
||||
@@ -1238,6 +1284,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-set"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.8"
|
||||
@@ -2043,6 +2098,18 @@ vision-worker = [
|
||||
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
windows = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "nuitka" },
|
||||
{ name = "ordered-set" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
{ name = "vs-camera", extra = ["genicam"], marker = "python_full_version >= '3.13'" },
|
||||
{ name = "vs-license", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "waitress" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", marker = "extra == 'dev'", specifier = ">=0.20.0" },
|
||||
@@ -2087,6 +2154,18 @@ requires-dist = [
|
||||
]
|
||||
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
windows = [
|
||||
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
||||
{ name = "nuitka", specifier = ">=2.5" },
|
||||
{ name = "ordered-set", specifier = ">=4.1" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
{ name = "vs-camera", extras = ["genicam"], marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-camera" },
|
||||
{ name = "vs-license", marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-license" },
|
||||
{ name = "waitress", specifier = ">=3.0.0" },
|
||||
{ name = "zstandard", specifier = ">=0.22" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinycss2"
|
||||
version = "1.5.1"
|
||||
@@ -2132,6 +2211,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
@@ -2230,6 +2318,31 @@ requires-dist = [
|
||||
]
|
||||
provides-extras = ["dev", "fast", "dxf"]
|
||||
|
||||
[[package]]
|
||||
name = "vs-camera"
|
||||
version = "0.1.0"
|
||||
source = { editable = "vendor/visionsuite/packages/vs-camera" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
genicam = [
|
||||
{ name = "harvesters" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "flask", marker = "extra == 'dev'", specifier = ">=3.0" },
|
||||
{ name = "harvesters", marker = "extra == 'genicam'", specifier = ">=1.4" },
|
||||
{ name = "netifaces", marker = "extra == 'rete'", specifier = ">=0.11" },
|
||||
{ name = "numpy", specifier = ">=1.24" },
|
||||
{ name = "opencv-python", specifier = ">=4.8" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||
]
|
||||
provides-extras = ["genicam", "rete", "dev"]
|
||||
|
||||
[[package]]
|
||||
name = "vs-dxf"
|
||||
version = "0.1.0"
|
||||
@@ -2256,6 +2369,21 @@ requires-dist = [
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "vs-license"
|
||||
version = "0.1.0"
|
||||
source = { editable = "vendor/visionsuite/packages/vs-license" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "cryptography", specifier = ">=42" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "vs-measure"
|
||||
version = "0.1.0"
|
||||
@@ -2360,6 +2488,15 @@ requires-dist = [
|
||||
]
|
||||
provides-extras = ["locate", "dxf", "dev"]
|
||||
|
||||
[[package]]
|
||||
name = "waitress"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
@@ -2576,3 +2713,77 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/f0/ad6e26aa06943ce9f1be4ae6738513a7b69d8ea1f3b13e46009a249a3f73/zopfli-0.4.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7", size = 124371, upload-time = "2026-02-13T14:17:24.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/36/3c15d564db6dfdd740919b205bdb69be75113e9919c422cde658e6d013c0/zopfli-0.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707", size = 102199, upload-time = "2026-02-13T14:17:26.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
|
||||
]
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/visionsuite updated: adf7a2640d...2e73691cc3
Reference in New Issue
Block a user