Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 242add047b | |||
| cd40b81f1e | |||
| 6e60e0b1a9 | |||
| 6bc6c0c0d5 | |||
| e5989c9d23 | |||
| 0af6692786 | |||
| 36074ab2f7 | |||
| fd1bbce8aa | |||
| 1453b31cee | |||
| 192d4b590c | |||
| b9f43a0ede | |||
| 5bf374aadd | |||
| 74fe5665b5 | |||
| be904bac07 |
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
[submodule "vendor/visionsuite"]
|
[submodule "vendor/visionsuite"]
|
||||||
path = 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)
|
## 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:
|
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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## Documentazione
|
||||||
|
|
||||||
Indice completo: [`docs/README.md`](docs/README.md).
|
Indice completo: [`docs/README.md`](docs/README.md).
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ services:
|
|||||||
# on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure,
|
# on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure,
|
||||||
# altrimenti il browser non lo rimanda mai indietro.
|
# altrimenti il browser non lo rimanda mai indietro.
|
||||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
||||||
|
# #72: da container si raggiunge il servizio camera (vs-gui) sull'host Linux
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -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
|
# The int-typed station_id param already guards against "by-code" being
|
||||||
# matched as a station id, but keeping the explicit order avoids surprises
|
# matched as a station id, but keeping the explicit order avoids surprises
|
||||||
# during refactors (e.g. if someone regroups handlers by HTTP method).
|
# during refactors (e.g. if someone regroups handlers by HTTP method).
|
||||||
|
@router.get("/by-code/{code}", response_model=StationResponse)
|
||||||
|
async def get_station_by_code(
|
||||||
|
code: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a station by its code (any authenticated user, like /by-code/{code}/recipes) (#72)."""
|
||||||
|
station = await station_service.get_station_by_code(db, code)
|
||||||
|
if station is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Station not found",
|
||||||
|
)
|
||||||
|
return StationResponse.model_validate(station)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
|
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
|
||||||
async def list_recipes_by_station_code(
|
async def list_recipes_by_station_code(
|
||||||
code: str,
|
code: str,
|
||||||
|
|||||||
+17
-2
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
|
|||||||
db_name: str = "tiemeasureflow"
|
db_name: str = "tiemeasureflow"
|
||||||
db_user: str = "tmflow"
|
db_user: str = "tmflow"
|
||||||
db_password: str = _DEFAULT_DB_PASSWORD
|
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)
|
# Presentation time zone (DB is UTC, #42)
|
||||||
display_timezone: str = "Europe/Rome"
|
display_timezone: str = "Europe/Rome"
|
||||||
@@ -78,7 +82,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""Async MySQL connection string."""
|
"""Async connection string: DB_URL if set, else the MySQL one."""
|
||||||
|
if self.db_url:
|
||||||
|
return self.db_url
|
||||||
return (
|
return (
|
||||||
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
||||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||||
@@ -97,7 +103,12 @@ class Settings(BaseSettings):
|
|||||||
(mounted as a Docker volume), not inside the backend tree.
|
(mounted as a Docker volume), not inside the backend tree.
|
||||||
"""
|
"""
|
||||||
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
||||||
return Path(__file__).resolve().parents[2] / self.upload_dir
|
# UPLOAD_DIR may be absolute (Windows package: C:/ProgramData/...):
|
||||||
|
# in that case it is used as is instead of being joined to the root.
|
||||||
|
configured = Path(self.upload_dir)
|
||||||
|
if configured.is_absolute():
|
||||||
|
return configured
|
||||||
|
return Path(__file__).resolve().parents[2] / configured
|
||||||
|
|
||||||
# Always resolve .env against the project root regardless of cwd
|
# Always resolve .env against the project root regardless of cwd
|
||||||
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
||||||
@@ -122,6 +133,10 @@ class Settings(BaseSettings):
|
|||||||
"SERVER_SECRET_KEY is still the committed default value; "
|
"SERVER_SECRET_KEY is still the committed default value; "
|
||||||
"set it before running with APP_ENV=" + self.app_env
|
"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:
|
if self.db_password == _DEFAULT_DB_PASSWORD:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"DB_PASSWORD is still the committed default value; "
|
"DB_PASSWORD is still the committed default value; "
|
||||||
|
|||||||
+20
-3
@@ -1,6 +1,7 @@
|
|||||||
"""Async SQLAlchemy database engine and session management."""
|
"""Async SQLAlchemy database engine and session management."""
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
@@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
|
|
||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
# Create async engine
|
_IS_SQLITE = settings.database_url.startswith("sqlite")
|
||||||
engine = create_async_engine(
|
|
||||||
|
# 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,
|
settings.database_url,
|
||||||
echo=False,
|
echo=False,
|
||||||
pool_size=10,
|
pool_size=10,
|
||||||
max_overflow=20,
|
max_overflow=20,
|
||||||
pool_recycle=3600,
|
pool_recycle=3600,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Session factory
|
# Session factory
|
||||||
async_session_factory = async_sessionmaker(
|
async_session_factory = async_sessionmaker(
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
# Idempotent on purpose (#71): a database born from create_all has no
|
||||||
|
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
|
||||||
|
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
|
||||||
|
# created and `alembic_version` still at 014. Every step checks first.
|
||||||
|
insp = sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
if 'api_keys' not in insp.get_table_names():
|
||||||
op.create_table(
|
op.create_table(
|
||||||
'api_keys',
|
'api_keys',
|
||||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
@@ -34,11 +41,24 @@ def upgrade() -> None:
|
|||||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
['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)
|
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'])
|
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:
|
with op.batch_alter_table('users') as batch:
|
||||||
|
if 'ix_users_api_key' in user_idx:
|
||||||
batch.drop_index('ix_users_api_key')
|
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_constraint('uq_users_api_key', type_='unique')
|
||||||
batch.drop_column('api_key')
|
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')
|
||||||
@@ -5,18 +5,35 @@ from typing import Optional
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
|
def _normalize_http_url(value: Optional[str], field: str) -> Optional[str]:
|
||||||
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
|
"""Vuoto/spazi -> None; altrimenti http(s) senza '/' finale."""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
if not value.lower().startswith(("http://", "https://")):
|
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("/")
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
|
||||||
|
return _normalize_http_url(value, "vision_worker_url")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_camera_url(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Vuoto/spazi -> None (nessuna camera); altrimenti http(s) senza '/' finale (#72)."""
|
||||||
|
return _normalize_http_url(value, "camera_url")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_camera_source(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Strip; vuoto -> None (#72)."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.strip() or None
|
||||||
|
|
||||||
|
|
||||||
class StationCreate(BaseModel):
|
class StationCreate(BaseModel):
|
||||||
code: str = Field(..., min_length=1, max_length=100)
|
code: str = Field(..., min_length=1, max_length=100)
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
@@ -25,8 +42,13 @@ class StationCreate(BaseModel):
|
|||||||
active: bool = True
|
active: bool = True
|
||||||
# worker di visione della stazione (#45); vuoto = centrale
|
# worker di visione della stazione (#45); vuoto = centrale
|
||||||
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
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_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||||
|
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
|
||||||
|
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
|
||||||
|
|
||||||
|
|
||||||
class StationUpdate(BaseModel):
|
class StationUpdate(BaseModel):
|
||||||
@@ -36,8 +58,13 @@ class StationUpdate(BaseModel):
|
|||||||
active: Optional[bool] = None
|
active: Optional[bool] = None
|
||||||
# worker di visione della stazione (#45); "" -> None riporta al centrale
|
# worker di visione della stazione (#45); "" -> None riporta al centrale
|
||||||
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
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_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||||
|
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
|
||||||
|
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
|
||||||
|
|
||||||
|
|
||||||
class StationResponse(BaseModel):
|
class StationResponse(BaseModel):
|
||||||
@@ -49,6 +76,8 @@ class StationResponse(BaseModel):
|
|||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
active: bool
|
active: bool
|
||||||
vision_worker_url: Optional[str] = None # #45
|
vision_worker_url: Optional[str] = None # #45
|
||||||
|
camera_url: Optional[str] = None # #72
|
||||||
|
camera_source: Optional[str] = None # #72
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: UtcDateTime
|
created_at: UtcDateTime
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ class Station(Base):
|
|||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
# URL del worker di visione della stazione (#45); NULL = worker centrale
|
# URL del worker di visione della stazione (#45); NULL = worker centrale
|
||||||
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# camera della stazione (#72): URL del servizio camera e sorgente da chiedergli
|
||||||
|
camera_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
camera_source: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, nullable=False, server_default=func.now()
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ async def create_station(
|
|||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
active=data.active,
|
active=data.active,
|
||||||
vision_worker_url=data.vision_worker_url, # #45
|
vision_worker_url=data.vision_worker_url, # #45
|
||||||
|
camera_url=data.camera_url, # #72
|
||||||
|
camera_source=data.camera_source, # #72
|
||||||
created_by=creator.id,
|
created_by=creator.id,
|
||||||
)
|
)
|
||||||
db.add(station)
|
db.add(station)
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from alembic import command
|
from alembic import command
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
||||||
|
|
||||||
from src.backend.config import Settings
|
from src.backend.config import Settings
|
||||||
@@ -132,6 +135,70 @@ class TestApiKeyHashMigration:
|
|||||||
command.upgrade(cfg, "head") # and up again
|
command.upgrade(cfg, "head") # and up again
|
||||||
assert "api_keys" in _inspect(db_path).get_table_names()
|
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:
|
class TestVisionRecipeDirMigration:
|
||||||
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
"""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
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
assert "vision_worker_url" in cols("stations")
|
assert "vision_worker_url" in cols("stations")
|
||||||
assert "worker_url" in cols("vision_results")
|
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")
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -430,3 +430,103 @@ async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin
|
|||||||
json={"vision_worker_url": "ftp://x"},
|
json={"vision_worker_url": "ftp://x"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
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,
|
all_task_ids=all_task_ids,
|
||||||
measurement_interval_minutes=measurement_interval_minutes,
|
measurement_interval_minutes=measurement_interval_minutes,
|
||||||
allow_manual_input=allow_manual_input,
|
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
|
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"])
|
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
@role_required("MeasurementTec")
|
@role_required("MeasurementTec")
|
||||||
|
|||||||
@@ -193,5 +193,9 @@ class APIClient:
|
|||||||
"""Return the list of active recipes assigned to the given station."""
|
"""Return the list of active recipes assigned to the given station."""
|
||||||
return self.get(f"/api/stations/by-code/{station_code}/recipes")
|
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()
|
api_client = APIClient()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function visionExecute() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
busy: false,
|
busy: false,
|
||||||
|
grabbing: false, // frame requested from the station camera (#72)
|
||||||
error: '',
|
error: '',
|
||||||
result: null, // last execute response
|
result: null, // last execute response
|
||||||
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
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.
|
// Input change: take the file and allow picking the same one again.
|
||||||
onPick(ev) {
|
onPick(ev) {
|
||||||
const f = ev.target.files && ev.target.files[0];
|
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>
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
|
||||||
<textarea x-model="form.notes" rows="3"
|
<textarea x-model="form.notes" rows="3"
|
||||||
@@ -464,6 +484,8 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
location: '',
|
location: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
vision_worker_url: '', // #45
|
vision_worker_url: '', // #45
|
||||||
|
camera_url: '', // #72
|
||||||
|
camera_source: '', // #72
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -517,7 +539,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
this.isEditing = false;
|
this.isEditing = false;
|
||||||
this.editingId = null;
|
this.editingId = null;
|
||||||
this.errorMsg = '';
|
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;
|
this.showModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -531,6 +553,8 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
location: station.location || '',
|
location: station.location || '',
|
||||||
notes: station.notes || '',
|
notes: station.notes || '',
|
||||||
vision_worker_url: station.vision_worker_url || '', // #45
|
vision_worker_url: station.vision_worker_url || '', // #45
|
||||||
|
camera_url: station.camera_url || '', // #72
|
||||||
|
camera_source: station.camera_source || '', // #72
|
||||||
active: station.active,
|
active: station.active,
|
||||||
};
|
};
|
||||||
this.showModal = true;
|
this.showModal = true;
|
||||||
@@ -551,6 +575,8 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
location: this.form.location || null,
|
location: this.form.location || null,
|
||||||
notes: this.form.notes || null,
|
notes: this.form.notes || null,
|
||||||
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
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,
|
active: this.form.active,
|
||||||
};
|
};
|
||||||
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
|
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
|
||||||
@@ -578,6 +604,8 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
location: this.form.location || null,
|
location: this.form.location || null,
|
||||||
notes: this.form.notes || null,
|
notes: this.form.notes || null,
|
||||||
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
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,
|
active: this.form.active,
|
||||||
};
|
};
|
||||||
const resp = await fetch('/admin/api/stations', {
|
const resp = await fetch('/admin/api/stations', {
|
||||||
|
|||||||
@@ -544,6 +544,15 @@
|
|||||||
<input type="file" accept="image/*" capture="environment" class="hidden"
|
<input type="file" accept="image/*" capture="environment" class="hidden"
|
||||||
@change="onPick($event)" :disabled="busy">
|
@change="onPick($event)" :disabled="busy">
|
||||||
</label>
|
</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>
|
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1136,6 +1145,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.__visionCfg = {
|
window.__visionCfg = {
|
||||||
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
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) }}',
|
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||||
taskId: {{ task.id|tojson }},
|
taskId: {{ task.id|tojson }},
|
||||||
taskType: {{ (task.task_type or '')|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(
|
mock_admin_api.put.assert_called_once_with(
|
||||||
"/api/stations/7", data={"vision_worker_url": None},
|
"/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
|
called_args, called_kwargs = mock_get.call_args
|
||||||
assert called_args[0] == "/api/stations/by-code/ST-001/recipes"
|
assert called_args[0] == "/api/stations/by-code/ST-001/recipes"
|
||||||
assert result == [{"id": 1, "code": "R1", "name": "R1", "active": True}]
|
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"
|
#~ msgid "Crea, modifica e gestisci gli utenti del sistema"
|
||||||
#~ msgstr "Create, edit and manage system users"
|
#~ 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"
|
#~ msgid "Crea, modifica e gestisci gli utenti del sistema"
|
||||||
#~ msgstr "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"
|
||||||
|
|||||||
Vendored
+1
-1
Submodule vendor/visionsuite updated: cdc7a14695...2e73691cc3
Reference in New Issue
Block a user