From b9f43a0eded984147a3511d5863184ebcc1f40dd Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 15 Oct 2026 09:50:14 +0200 Subject: [PATCH 1/4] 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 --- src/backend/api/routers/stations.py | 15 +++ .../migrations/versions/020_station_camera.py | 33 +++++++ src/backend/models/api/station.py | 35 ++++++- src/backend/models/orm/station.py | 3 + src/backend/services/station_service.py | 2 + src/backend/tests/test_migrations.py | 20 ++++ src/backend/tests/test_stations_api.py | 92 +++++++++++++++++++ 7 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 src/backend/migrations/versions/020_station_camera.py diff --git a/src/backend/api/routers/stations.py b/src/backend/api/routers/stations.py index 3935a16..0333544 100644 --- a/src/backend/api/routers/stations.py +++ b/src/backend/api/routers/stations.py @@ -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, + admin: User = Depends(require_admin_user), + db: AsyncSession = Depends(get_db), +): + """Get a station by its code (admin only, like GET /{station_id}) (#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, diff --git a/src/backend/migrations/versions/020_station_camera.py b/src/backend/migrations/versions/020_station_camera.py new file mode 100644 index 0000000..13f7c32 --- /dev/null +++ b/src/backend/migrations/versions/020_station_camera.py @@ -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: +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') diff --git a/src/backend/models/api/station.py b/src/backend/models/api/station.py index 6fb10bc..3024704 100644 --- a/src/backend/models/api/station.py +++ b/src/backend/models/api/station.py @@ -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 diff --git a/src/backend/models/orm/station.py b/src/backend/models/orm/station.py index 973bcaa..4d2cf32 100644 --- a/src/backend/models/orm/station.py +++ b/src/backend/models/orm/station.py @@ -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() diff --git a/src/backend/services/station_service.py b/src/backend/services/station_service.py index eb35dfa..14c4a31 100644 --- a/src/backend/services/station_service.py +++ b/src/backend/services/station_service.py @@ -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) diff --git a/src/backend/tests/test_migrations.py b/src/backend/tests/test_migrations.py index 613e426..5d4baf4 100644 --- a/src/backend/tests/test_migrations.py +++ b/src/backend/tests/test_migrations.py @@ -321,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") diff --git a/src/backend/tests/test_stations_api.py b/src/backend/tests/test_stations_api.py index 87551d2..7b15eca 100644 --- a/src/backend/tests/test_stations_api.py +++ b/src/backend/tests/test_stations_api.py @@ -430,3 +430,95 @@ 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_requires_admin(client: AsyncClient, maker_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(maker_user)) + assert resp.status_code == 403 From 192d4b590cebfdea3f95485fa130efa664fa5f0a Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 15 Oct 2026 09:51:07 +0200 Subject: [PATCH 2/4] Camera di stazione: acquisizione dal frontend e campi admin (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docker-compose.dev.yml | 3 + src/frontend/flask_app/blueprints/measure.py | 59 +++++++ src/frontend/flask_app/services/api_client.py | 4 + .../flask_app/static/js/vision_execute.js | 29 ++++ .../flask_app/templates/admin/stations.html | 30 +++- .../templates/measure/task_execute.html | 10 ++ .../flask_app/tests/test_admin_stations.py | 39 +++++ .../tests/test_api_client_stations.py | 9 + .../tests/test_measure_camera_grab.py | 161 ++++++++++++++++++ .../translations/en/LC_MESSAGES/messages.po | 37 ++++ .../translations/it/LC_MESSAGES/messages.po | 37 ++++ 11 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 src/frontend/flask_app/tests/test_measure_camera_grab.py diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d91046d..495d9e0 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index 749d2bb..d5acbd7 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -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//image", methods=["GET"]) @login_required @role_required("MeasurementTec") diff --git a/src/frontend/flask_app/services/api_client.py b/src/frontend/flask_app/services/api_client.py index a1dd429..b83de02 100644 --- a/src/frontend/flask_app/services/api_client.py +++ b/src/frontend/flask_app/services/api_client.py @@ -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() diff --git a/src/frontend/flask_app/static/js/vision_execute.js b/src/frontend/flask_app/static/js/vision_execute.js index 981c567..3e22aac 100644 --- a/src/frontend/flask_app/static/js/vision_execute.js +++ b/src/frontend/flask_app/static/js/vision_execute.js @@ -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]; diff --git a/src/frontend/flask_app/templates/admin/stations.html b/src/frontend/flask_app/templates/admin/stations.html index be9e765..21a42d6 100644 --- a/src/frontend/flask_app/templates/admin/stations.html +++ b/src/frontend/flask_app/templates/admin/stations.html @@ -215,6 +215,26 @@

{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}

+ {# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #} +
+ + +

{{ _('Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale') }}

+
+
+ + +

{{ _('Es. camera: oppure camera:folder') }}

+
+