diff --git a/src/frontend/flask_app/blueprints/admin.py b/src/frontend/flask_app/blueprints/admin.py index 37951a0..77b2f8e 100644 --- a/src/frontend/flask_app/blueprints/admin.py +++ b/src/frontend/flask_app/blueprints/admin.py @@ -1,4 +1,6 @@ """Admin blueprint - user management.""" +from urllib.parse import urlparse # #88: validazione url camera +import requests as http_requests # #88: lettura sorgenti dall'acquisitore (come maker.py) from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for from flask_babel import gettext as _ @@ -170,6 +172,39 @@ def api_toggle_active(user_id: int): # --- Stations --- +@admin_bp.route("/api/camera/sources", methods=["GET"]) +@login_required +@admin_required +def api_camera_sources(): + """Proxy (#88): elenco sorgenti di un acquisitore, GET /camera/sources.""" + url = (request.args.get("url") or "").strip() + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return jsonify({"error": True, "detail": _("Indirizzo della camera non valido")}), 400 + try: + resp = http_requests.get(f"{url.rstrip('/')}/camera/sources", timeout=5) + except http_requests.RequestException as exc: + return jsonify({ + "error": True, + "detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)), + }), 502 + invalid = (jsonify({"error": True, "detail": _("Risposta non valida dal servizio camera")}), 502) + if resp.status_code != 200: + return invalid + try: + sources = resp.json().get("sources") + except (ValueError, AttributeError): + return invalid + if not isinstance(sources, list): + return invalid + # solo i tre campi che la UI usa, sempre stringhe + clean = [ + {k: str(s.get(k, "")) for k in ("name", "label", "kind")} + for s in sources if isinstance(s, dict) + ] + return jsonify({"sources": clean}), 200 + + @admin_bp.route("/api/stations", methods=["POST"]) @login_required @admin_required diff --git a/src/frontend/flask_app/blueprints/maker.py b/src/frontend/flask_app/blueprints/maker.py index a322bcd..3765ae7 100644 --- a/src/frontend/flask_app/blueprints/maker.py +++ b/src/frontend/flask_app/blueprints/maker.py @@ -598,7 +598,7 @@ def api_station_camera_calibration(station_id: int): }), 409 camera_url, camera_source = camera base = camera_url.rstrip("/") - link = f"{base}/calibrazione" # pagina di calibrazione dell'acquisitore (C4) + link = f"{base}/acquisizione" # pagina di acquisizione dell'acquisitore (#88; era /calibrazione, C4) try: resp = http_requests.get( f"{base}/camera/calibration", diff --git a/src/frontend/flask_app/static/js/station_calibration.js b/src/frontend/flask_app/static/js/station_calibration.js index 248549e..7424474 100644 --- a/src/frontend/flask_app/static/js/station_calibration.js +++ b/src/frontend/flask_app/static/js/station_calibration.js @@ -40,8 +40,8 @@ function stationCalibrationCell(station, msgs) { if (!this.hasCamera) return; const r = await fetchStationCalibration(station.id); this.state = r.state || 'unreachable'; - // Link «Calibra»: dal proxy, altrimenti costruito dall'indirizzo della camera - this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/calibrazione'); + // Link «Acquisizione» (#88, era «Calibra»): dal proxy, altrimenti costruito dall'indirizzo della camera + this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/acquisizione'); if (this.state === 'calibrated') this.label = formatCalibration(r.calibration || {}); else if (this.state === 'uncalibrated') this.label = this.msgs.uncalibrated || 'not calibrated'; else this.label = this.msgs.unreachable || 'camera service unreachable'; diff --git a/src/frontend/flask_app/templates/admin/stations.html b/src/frontend/flask_app/templates/admin/stations.html index c004a1d..30057ea 100644 --- a/src/frontend/flask_app/templates/admin/stations.html +++ b/src/frontend/flask_app/templates/admin/stations.html @@ -97,7 +97,7 @@ {{ _('Calibra') }} + class="font-medium text-primary hover:underline">{{ _('Acquisizione') }} {# #88: era «Calibra» #} @@ -165,7 +165,7 @@ -
@@ -238,21 +238,42 @@ {# 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') }}

+ {# Sorgente camera (#88): testo libero con suggerimenti letti dall'acquisitore (datalist) #}
- -

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

+
+ + + + + +
+

+

{{ _("Sorgente non presente nell'acquisitore") }}

+ {{ _('Apri pagina acquisizione') }}
@@ -289,14 +310,14 @@ class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50"> - {{ _('Salvataggio...') }} + {{ _('Salvataggio...') }}
-
@@ -417,7 +438,7 @@
-
@@ -449,7 +470,7 @@
-
s.name === src); + }, + openCreateModal() { this.isEditing = false; this.editingId = null; this.errorMsg = ''; this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', camera_url: '', camera_source: '', active: true }; + this.loadCameraSources(); // #88: svuota lo stato delle sorgenti this.showModal = true; }, @@ -578,6 +640,7 @@ function stationManagement(initialStations, initialRecipes) { camera_source: station.camera_source || '', // #72 active: station.active, }; + this.loadCameraSources(); // #88: se la camera e' valorizzata legge subito le sorgenti this.showModal = true; }, diff --git a/src/frontend/flask_app/tests/test_admin_stations.py b/src/frontend/flask_app/tests/test_admin_stations.py index 6fd8472..084fc79 100644 --- a/src/frontend/flask_app/tests/test_admin_stations.py +++ b/src/frontend/flask_app/tests/test_admin_stations.py @@ -254,3 +254,74 @@ def test_create_station_proxy_forwards_the_camera_fields(logged_in_client, mock_ 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) + + +# --- Sorgenti camera lette dall'acquisitore (#88) --- + +def _fake_sources_response(status=200, payload=None): + resp = MagicMock() + resp.status_code = status + resp.json.return_value = payload + return resp + + +def test_camera_sources_proxy_keeps_only_the_three_fields(logged_in_client, mock_admin_api): + payload = {"sources": [ + {"name": "camera:folder", "label": "Cartella", "kind": "folder", "extra": 1}, + {"name": "camera:123", "label": "Basler", "kind": "gige"}, + ]} + with patch("blueprints.admin.http_requests.get", + return_value=_fake_sources_response(200, payload)) as get: + resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765/") + assert resp.status_code == 200 + assert resp.get_json()["sources"][0] == { + "name": "camera:folder", "label": "Cartella", "kind": "folder"} + assert len(resp.get_json()["sources"]) == 2 + assert get.call_args.args[0] == "http://acq:8765/camera/sources" + assert get.call_args.kwargs["timeout"] == 5 + + +@pytest.mark.parametrize("url", ["ftp://x", "", "http://", "acq:8765"]) +def test_camera_sources_proxy_rejects_invalid_url(logged_in_client, mock_admin_api, url): + with patch("blueprints.admin.http_requests.get") as get: + resp = logged_in_client.get("/admin/api/camera/sources", query_string={"url": url}) + assert resp.status_code == 400 + assert resp.get_json()["error"] is True + get.assert_not_called() + + +def test_camera_sources_proxy_unreachable_is_502(logged_in_client, mock_admin_api): + import requests + with patch("blueprints.admin.http_requests.get", + side_effect=requests.ConnectionError("rifiutata")): + resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765") + assert resp.status_code == 502 + assert "rifiutata" in resp.get_json()["detail"] + + +@pytest.mark.parametrize("status,payload", [(200, {"altro": 1}), (200, {"sources": "x"}), (500, {"sources": []})]) +def test_camera_sources_proxy_invalid_response_is_502(logged_in_client, mock_admin_api, status, payload): + with patch("blueprints.admin.http_requests.get", + return_value=_fake_sources_response(status, payload)): + resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765") + assert resp.status_code == 502 + + +def test_camera_sources_proxy_non_admin_is_redirected(client, mock_admin_api): + with client.session_transaction() as sess: + sess["api_key"] = "test-key" + sess["user"] = {"id": 2, "username": "operator", "roles": ["MeasurementTec"], + "is_admin": False, "active": True} + sess["user_id"] = 2 + with patch("blueprints.admin.http_requests.get") as get: + resp = client.get("/admin/api/camera/sources?url=http://acq:8765") + assert resp.status_code in (301, 302) + get.assert_not_called() + + +def test_station_form_has_the_camera_sources_picker(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 "cameraSourcesList" in html + assert "loadCameraSources" in html + assert "/acquisizione" in html diff --git a/src/frontend/flask_app/tests/test_dxf_recipe_routes.py b/src/frontend/flask_app/tests/test_dxf_recipe_routes.py index 22c4cd9..a1cebe8 100644 --- a/src/frontend/flask_app/tests/test_dxf_recipe_routes.py +++ b/src/frontend/flask_app/tests/test_dxf_recipe_routes.py @@ -120,7 +120,7 @@ class TestStationCameraCalibration: assert resp.status_code == 200 assert body["state"] == "calibrated" assert body["calibration"]["px_per_mm"] == 20.03 - assert body["calibration_url"] == "http://acq:8765/calibrazione" + assert body["calibration_url"] == "http://acq:8765/acquisizione" args, kwargs = get.call_args assert args[0] == "http://acq:8765/camera/calibration" assert kwargs["params"] == {"source": "camera:folder"} @@ -130,7 +130,7 @@ class TestStationCameraCalibration: resp, _ = self._get(logged_in_client, _fake(404, b"{}", {"detail": "no"})) assert resp.status_code == 200 assert resp.get_json()["state"] == "uncalibrated" - assert resp.get_json()["calibration_url"] == "http://acq:8765/calibrazione" + assert resp.get_json()["calibration_url"] == "http://acq:8765/acquisizione" def test_unreachable(self, logged_in_client, mock_api_client): _stations(mock_api_client) diff --git a/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po b/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po index 994585b..b2f5b53 100644 --- a/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po +++ b/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po @@ -3452,3 +3452,36 @@ msgstr "Try" #: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py msgid "Salva ricetta" msgstr "Save recipe" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Acquisizione" +msgstr "Acquisition" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Leggi sorgenti" +msgstr "Read sources" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Lettura..." +msgstr "Reading..." + +#: templates/admin/stations.html blueprints/admin.py (#88) +#, python-format +msgid "%(n)s sorgenti trovate" +msgstr "%(n)s sources found" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Sorgente non presente nell'acquisitore" +msgstr "Source not present in the acquisition service" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Apri pagina acquisizione" +msgstr "Open acquisition page" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Indirizzo della camera non valido" +msgstr "Invalid camera address" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Risposta non valida dal servizio camera" +msgstr "Invalid response from the camera service" diff --git a/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po b/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po index ae14ee7..1fa2150 100644 --- a/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po +++ b/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po @@ -3440,3 +3440,36 @@ msgstr "Prova" #: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py msgid "Salva ricetta" msgstr "Salva ricetta" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Acquisizione" +msgstr "Acquisizione" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Leggi sorgenti" +msgstr "Leggi sorgenti" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Lettura..." +msgstr "Lettura..." + +#: templates/admin/stations.html blueprints/admin.py (#88) +#, python-format +msgid "%(n)s sorgenti trovate" +msgstr "%(n)s sorgenti trovate" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Sorgente non presente nell'acquisitore" +msgstr "Sorgente non presente nell'acquisitore" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Apri pagina acquisizione" +msgstr "Apri pagina acquisizione" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Indirizzo della camera non valido" +msgstr "Indirizzo della camera non valido" + +#: templates/admin/stations.html blueprints/admin.py (#88) +msgid "Risposta non valida dal servizio camera" +msgstr "Risposta non valida dal servizio camera"