Camera di stazione: acquisizione dal frontend e campi admin (#72)

Route POST /measure/api/vision/grab (proxy verso il servizio camera),
get_station_by_code, pulsante «Acquisisci dalla camera», grab() in
vision_execute.js, campi camera_url e camera_source nell'admin stazioni,
traduzioni it/en, extra_hosts host.docker.internal nel compose di sviluppo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 09:51:07 +02:00
parent 5bf374aadd
commit 192d4b590c
11 changed files with 417 additions and 1 deletions
@@ -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