Compare commits

...

14 Commits

Author SHA1 Message Date
Adriano 242add047b Backend su file SQLite locale: DB_URL, PRAGMA FK/WAL, UPLOAD_DIR assoluto (TieMeasureFlow#77)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:07:45 +02:00
Adriano cd40b81f1e Merge lavoro/daitarn-tmf-core2e7: vendor TieVision-Core 2e73691 (Fixes #75) 2026-10-17 00:39:43 +02:00
Adriano 6e60e0b1a9 vendor TieVision-Core 4c178cf -> 2e73691: CompareDxf dxf_path su recipe_dir, catalogo EdgesBetweenSegments (#75)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8FDdHxKu17fSf9T6aZETK
2026-10-17 00:39:36 +02:00
Adriano 6bc6c0c0d5 Submodule vendor/visionsuite: da visionsuite a TieVision-Core, pin 4c178cf (#74)
Il repo e' stato rinominato il 15/10/2026 (TieVision-Core#329): .gitmodules punta
al nuovo URL, il pin passa da cdc7a14 a 4c178cf, CLAUDE.md e il commento di
scripts/camera_service.py seguono la divisione (vs-gui e vs-assistant in
TieVision-Studio e TieVision-Assistant). Test visione: 29 passed prima e dopo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 17:49:13 +02:00
Adriano e5989c9d23 Merge lavoro/jeeg-tmf72: camera di stazione per la demo in fiera (#72)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 10:02:50 +02:00
Adriano 0af6692786 Servizio camera minimo di stazione con test e README (#72)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 10:01:46 +02:00
Adriano 36074ab2f7 Merge frontend camera di stazione (#72) 2026-10-15 09:53:41 +02:00
Adriano fd1bbce8aa Merge backend camera di stazione (#72) 2026-10-15 09:53:41 +02:00
Adriano 1453b31cee by-code: auth da utente loggato qualunque, come /recipes (#72)
Lo chiama il proxy Flask con la chiave dell'operatore MeasurementTec, quindi
get_current_user al posto di require_admin_user. Test: MeasurementTec 200,
senza chiave 401.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:53:33 +02:00
Adriano 192d4b590c Camera di stazione: acquisizione dal frontend e campi admin (#72)
Route POST /measure/api/vision/grab (proxy verso il servizio camera),
get_station_by_code, pulsante «Acquisisci dalla camera», grab() in
vision_execute.js, campi camera_url e camera_source nell'admin stazioni,
traduzioni it/en, extra_hosts host.docker.internal nel compose di sviluppo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:51:07 +02:00
Adriano b9f43a0ede Stazioni: camera_url e camera_source, GET /api/stations/by-code/{code} (#72)
Migrazione 020 con le due colonne su stations, campi in modello, schemi e
servizio (validatore http(s) come vision_worker_url), nuova route by-code
dichiarata prima di /{station_id}. Test di migrazione su e giu e di API.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:50:14 +02:00
Adriano 5bf374aadd Merge lavoro/jeeg-tmf71: 015_api_key_hash idempotente (Fixes #71)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 09:28:40 +02:00
Adriano 74fe5665b5 015_api_key_hash idempotente: DB da create_all e 015 a metà su MySQL (#71)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:25:07 +02:00
Adriano be904bac07 Merge lavoro/daitarn-tmf73: vendor visionsuite cdc7a14 (Fixes #73)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcYsFQDaTPnDVjrQkWj8R
2026-10-13 09:26:04 +02:00
28 changed files with 1191 additions and 34 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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:
+16
View File
@@ -516,6 +516,22 @@ 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`.
---
## Documentazione
Indice completo: [`docs/README.md`](docs/README.md).
+3
View File
@@ -88,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
+144
View File
@@ -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()
+116
View File
@@ -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"]
+15
View File
@@ -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,
+17 -2
View File
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
db_name: str = "tiemeasureflow"
db_user: str = "tmflow"
db_password: str = _DEFAULT_DB_PASSWORD
# Full SQLAlchemy async URL (env DB_URL). When set it wins over the
# DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD parts. Used by the Windows
# package: sqlite+aiosqlite:///C:/ProgramData/.../tmf.db (forward slashes).
db_url: str | None = None
# Presentation time zone (DB is UTC, #42)
display_timezone: str = "Europe/Rome"
@@ -78,7 +82,9 @@ class Settings(BaseSettings):
@property
def database_url(self) -> str:
"""Async MySQL connection string."""
"""Async connection string: DB_URL if set, else the MySQL one."""
if self.db_url:
return self.db_url
return (
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}"
@@ -97,7 +103,12 @@ class Settings(BaseSettings):
(mounted as a Docker volume), not inside the backend tree.
"""
# Path(__file__) = src/backend/config.py → parents[2] = project root
return Path(__file__).resolve().parents[2] / self.upload_dir
# UPLOAD_DIR may be absolute (Windows package: C:/ProgramData/...):
# in that case it is used as is instead of being joined to the root.
configured = Path(self.upload_dir)
if configured.is_absolute():
return configured
return Path(__file__).resolve().parents[2] / configured
# Always resolve .env against the project root regardless of cwd
# (pydantic-settings would otherwise treat the path as cwd-relative).
@@ -122,6 +133,10 @@ class Settings(BaseSettings):
"SERVER_SECRET_KEY is still the committed default value; "
"set it before running with APP_ENV=" + self.app_env
)
# A SQLite file has no DB password: the default-password guard only
# makes sense for the MySQL connection built from the DB_* parts.
if self.db_url and self.db_url.startswith("sqlite"):
return self
if self.db_password == _DEFAULT_DB_PASSWORD:
raise RuntimeError(
"DB_PASSWORD is still the committed default value; "
+25 -8
View File
@@ -1,6 +1,7 @@
"""Async SQLAlchemy database engine and session management."""
from collections.abc import AsyncGenerator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
@@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase
from src.backend.config import settings
# Create async engine
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_recycle=3600,
)
_IS_SQLITE = settings.database_url.startswith("sqlite")
# Create async engine. MySQL gets a connection pool; SQLite (local file of the
# Windows package) does not accept pool_size/max_overflow/pool_recycle.
if _IS_SQLITE:
engine = create_async_engine(settings.database_url, echo=False)
@event.listens_for(engine.sync_engine, "connect")
def _sqlite_pragmas(dbapi_connection, _connection_record) -> None:
"""Per-connection PRAGMAs: SQLite ignores FKs unless asked, and WAL +
busy_timeout let the web, vision and backend threads share the file."""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
else:
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_recycle=3600,
)
# Session factory
async_session_factory = async_sessionmaker(
@@ -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,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')
+32 -3
View File
@@ -5,18 +5,35 @@ from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
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("vision_worker_url must start with http:// or 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):
code: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
@@ -25,8 +42,13 @@ class StationCreate(BaseModel):
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):
@@ -36,8 +58,13 @@ class StationUpdate(BaseModel):
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):
@@ -49,6 +76,8 @@ class StationResponse(BaseModel):
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
+3
View File
@@ -27,6 +27,9 @@ class Station(Base):
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
# URL del worker di visione della stazione (#45); NULL = worker centrale
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# camera della stazione (#72): URL del servizio camera e sorgente da chiedergli
camera_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
camera_source: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
+2
View File
@@ -32,6 +32,8 @@ async def create_station(
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)
+87
View File
@@ -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)."""
@@ -254,3 +321,23 @@ class TestStationVisionWorkerMigration:
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")
+144
View File
@@ -0,0 +1,144 @@
"""Backend su un file SQLite locale (pacchetto Windows, TieMeasureFlow#77).
Il modulo `database` costruisce l'engine all'import leggendo l'ambiente, quindi
i test che riguardano DB_URL girano in un sottoprocesso con DB_URL/UPLOAD_DIR
impostati: e' lo stesso percorso di avvio.py nel pacchetto, senza toccare
l'engine in memoria usato dal resto della suite.
"""
import json
import os
import subprocess
import sys
import textwrap
from pathlib import Path
from src.backend.config import Settings
_ROOT = Path(__file__).resolve().parents[3]
# Script eseguito nel sottoprocesso: upgrade head da DB vuoto, poi app reale
# (health, login con utente creato via ORM), PRAGMA e vincolo FK.
_SCRIPT = textwrap.dedent(
"""
import asyncio, json, sys
from pathlib import Path
from unittest.mock import MagicMock
# WeasyPrint senza GTK: stesso mock di conftest.py (le DLL sono affare del piano B)
sys.modules.setdefault("weasyprint", MagicMock())
from alembic import command
from alembic.config import Config
from src.backend.config import settings
ini = Path(r"{ini}")
command.upgrade(Config(str(ini)), "head")
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select, text
from sqlalchemy.exc import IntegrityError
from src.backend.database import async_session_factory, engine
from src.backend.main import app
from src.backend.models.orm.user import User
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.services.auth_service import hash_password
async def main():
out = {{}}
async with async_session_factory() as s:
s.add(User(username="fiera", password_hash=hash_password("Pass12345"),
display_name="Fiera", roles=["Maker"], is_admin=True))
await s.commit()
async with async_session_factory() as s:
out["utente"] = (await s.execute(select(User.username))).scalars().all()
out["fk"] = (await s.execute(text("PRAGMA foreign_keys"))).scalar()
out["journal"] = (await s.execute(text("PRAGMA journal_mode"))).scalar()
out["busy"] = (await s.execute(text("PRAGMA busy_timeout"))).scalar()
# FK attive: una versione con recipe_id inesistente deve essere rifiutata
try:
async with async_session_factory() as s:
s.add(RecipeVersion(recipe_id=99999, version_number=1, created_by=1))
await s.commit()
out["fk_violation"] = False
except IntegrityError:
out["fk_violation"] = True
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
out["health"] = (await c.get("/api/health")).status_code
r = await c.post("/api/auth/login", json={{"username": "fiera", "password": "Pass12345"}})
out["login"] = r.status_code
out["upload_path"] = str(settings.upload_path)
out["database_url"] = settings.database_url
await engine.dispose()
print("RISULTATO=" + json.dumps(out))
asyncio.run(main())
"""
)
def _run(tmp_path: Path, extra_env: dict[str, str]) -> dict:
ini = _ROOT / "src" / "backend" / "migrations" / "alembic.ini"
env = {k: v for k, v in os.environ.items() if k not in ("DB_URL", "UPLOAD_DIR")}
env.update(extra_env)
env["PYTHONPATH"] = str(_ROOT)
env["PYTHONIOENCODING"] = "utf-8"
proc = subprocess.run(
[sys.executable, "-c", _SCRIPT.format(ini=str(ini))],
cwd=str(_ROOT), env=env, capture_output=True, text=True, timeout=180,
)
assert proc.returncode == 0, proc.stderr[-3000:]
line = [l for l in proc.stdout.splitlines() if l.startswith("RISULTATO=")][-1]
return json.loads(line[len("RISULTATO="):])
def test_file_sqlite_upgrade_app_pragma_e_upload_assoluto(tmp_path):
db = tmp_path / "tmf.db"
uploads = tmp_path / "uploads"
# barre "/" nell'URL come nel pacchetto Windows (D:/... non va in escape)
url = f"sqlite+aiosqlite:///{db.as_posix()}"
out = _run(tmp_path, {"DB_URL": url, "UPLOAD_DIR": str(uploads)})
assert out["database_url"] == url
assert db.exists() # il file e' stato creato da upgrade head
assert out["utente"] == ["fiera"]
assert out["health"] == 200
assert out["login"] == 200
assert out["fk"] == 1 # PRAGMA foreign_keys attivo
assert out["fk_violation"] is True
assert out["journal"] == "wal"
assert out["busy"] == 5000
# UPLOAD_DIR assoluto rispettato (non accodato alla radice del progetto)
assert Path(out["upload_path"]) == uploads
def test_database_url_senza_db_url_resta_mysql(monkeypatch):
monkeypatch.delenv("DB_URL", raising=False)
s = Settings(_env_file=None, db_url=None)
assert s.database_url.startswith("mysql+asyncmy://")
def test_db_url_vince_sulle_parti_db(monkeypatch):
s = Settings(_env_file=None, db_url="sqlite+aiosqlite:///C:/x/tmf.db", db_host="altro")
assert s.database_url == "sqlite+aiosqlite:///C:/x/tmf.db"
def test_produzione_con_sqlite_non_richiede_db_password():
# niente RuntimeError: con SQLite la password DB di default non conta
Settings(
_env_file=None, app_env="production", server_secret_key="x" * 40,
db_url="sqlite+aiosqlite:///C:/x/tmf.db",
)
def test_produzione_mysql_rifiuta_ancora_password_di_default():
import pytest
with pytest.raises(Exception, match="DB_PASSWORD"):
Settings(_env_file=None, app_env="production", server_secret_key="x" * 40)
def test_upload_dir_relativo_resta_sotto_la_radice():
s = Settings(_env_file=None, upload_dir="uploads")
assert s.upload_path == _ROOT / "uploads"
def test_upload_dir_assoluto(tmp_path):
s = Settings(_env_file=None, upload_dir=str(tmp_path / "u"))
assert s.upload_path == tmp_path / "u"
+100
View File
@@ -430,3 +430,103 @@ async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin
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
@@ -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")
@@ -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];
@@ -215,6 +215,26 @@
<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"
@@ -464,6 +484,8 @@ function stationManagement(initialStations, initialRecipes) {
location: '',
notes: '',
vision_worker_url: '', // #45
camera_url: '', // #72
camera_source: '', // #72
active: true,
},
@@ -517,7 +539,7 @@ function stationManagement(initialStations, initialRecipes) {
this.isEditing = false;
this.editingId = null;
this.errorMsg = '';
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', active: true };
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', camera_url: '', camera_source: '', active: true };
this.showModal = true;
},
@@ -531,6 +553,8 @@ function stationManagement(initialStations, initialRecipes) {
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;
@@ -551,6 +575,8 @@ function stationManagement(initialStations, initialRecipes) {
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}`, {
@@ -578,6 +604,8 @@ function stationManagement(initialStations, initialRecipes) {
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', {
@@ -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 }},
@@ -215,3 +215,42 @@ def test_station_proxies_pass_the_vision_worker_url_untouched(logged_in_client,
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,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
@@ -3322,3 +3322,40 @@ msgstr "Attachments"
#~ 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"
@@ -3310,3 +3310,40 @@ msgstr "Allegati"
#~ 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"