Stazioni: sorgenti camera lette dall'acquisitore, link Acquisizione (#88)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
This commit is contained in:
2026-10-24 14:51:22 +02:00
parent 2760623b53
commit 390d6210d0
8 changed files with 253 additions and 18 deletions
@@ -1,4 +1,6 @@
"""Admin blueprint - user management.""" """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 import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
@@ -170,6 +172,39 @@ def api_toggle_active(user_id: int):
# --- Stations --- # --- 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 <url>/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"]) @admin_bp.route("/api/stations", methods=["POST"])
@login_required @login_required
@admin_required @admin_required
+1 -1
View File
@@ -598,7 +598,7 @@ def api_station_camera_calibration(station_id: int):
}), 409 }), 409
camera_url, camera_source = camera camera_url, camera_source = camera
base = camera_url.rstrip("/") 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: try:
resp = http_requests.get( resp = http_requests.get(
f"{base}/camera/calibration", f"{base}/camera/calibration",
@@ -40,8 +40,8 @@ function stationCalibrationCell(station, msgs) {
if (!this.hasCamera) return; if (!this.hasCamera) return;
const r = await fetchStationCalibration(station.id); const r = await fetchStationCalibration(station.id);
this.state = r.state || 'unreachable'; this.state = r.state || 'unreachable';
// Link «Calibra»: dal proxy, altrimenti costruito dall'indirizzo della camera // Link «Acquisizione» (#88, era «Calibra»): dal proxy, altrimenti costruito dall'indirizzo della camera
this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/calibrazione'); this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/acquisizione');
if (this.state === 'calibrated') this.label = formatCalibration(r.calibration || {}); if (this.state === 'calibrated') this.label = formatCalibration(r.calibration || {});
else if (this.state === 'uncalibrated') this.label = this.msgs.uncalibrated || 'not calibrated'; else if (this.state === 'uncalibrated') this.label = this.msgs.uncalibrated || 'not calibrated';
else this.label = this.msgs.unreachable || 'camera service unreachable'; else this.label = this.msgs.unreachable || 'camera service unreachable';
@@ -97,7 +97,7 @@
</span> </span>
<a :href="calibrationUrl" target="_blank" rel="noopener" <a :href="calibrationUrl" target="_blank" rel="noopener"
x-show="calibrationUrl" x-show="calibrationUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a> class="font-medium text-primary hover:underline">{{ _('Acquisizione') }}</a> {# #88: era «Calibra» #}
</div> </div>
</template> </template>
</td> </td>
@@ -165,7 +165,7 @@
</div> </div>
<!-- Modal: Create / Edit Station --> <!-- Modal: Create / Edit Station -->
<div x-show="showModal" x-cloak <div x-show="showModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4" class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeModal()"> @keydown.escape.window="closeModal()">
<div class="absolute inset-0 bg-black/50" @click="closeModal()"></div> <div class="absolute inset-0 bg-black/50" @click="closeModal()"></div>
@@ -238,21 +238,42 @@
{# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #} {# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #}
<div> <div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Camera (URL)') }}</label> <label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Camera (URL)') }}</label>
<input type="text" x-model="form.camera_url" <input type="text" x-model="form.camera_url" @change="loadCameraSources()"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] 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" 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" placeholder="host:8765"
maxlength="500"> 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> <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>
{# Sorgente camera (#88): testo libero con suggerimenti letti dall'acquisitore (datalist) #}
<div> <div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label> <label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label>
<input type="text" x-model="form.camera_source" <div class="flex gap-2">
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] <input type="text" x-model="form.camera_source" list="cameraSourcesList"
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors" class="flex-1 min-w-0 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
placeholder="camera:folder" text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
maxlength="200"> placeholder="camera:folder"
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Es. camera:<seriale> oppure camera:folder') }}</p> maxlength="200">
<datalist id="cameraSourcesList">
<template x-for="s in cameraSources" :key="s.name">
<option :value="s.name" :label="s.label"></option>
</template>
</datalist>
<button type="button" @click="loadCameraSources()"
:disabled="!(form.camera_url || '').trim() || cameraSourcesLoading"
class="px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-medium
text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap">
{{ _('Leggi sorgenti') }}
</button>
</div>
<p class="mt-1 text-xs"
:class="cameraSourcesError ? 'text-red-600 dark:text-red-400' : 'text-[var(--text-secondary)]'"
x-text="cameraSourcesStatus || {{ _('Es. camera:<seriale> oppure camera:folder')|tojson_attr }}"></p>
<p x-show="cameraSourceMissing" class="mt-1 text-xs text-amber-700 dark:text-amber-300">{{ _("Sorgente non presente nell'acquisitore") }}</p>
<a x-show="(form.camera_url || '').trim()"
:href="(form.camera_url || '').trim().replace(/\/+$/, '') + '/acquisizione'"
target="_blank" rel="noopener"
class="mt-1 inline-block text-xs font-medium text-primary hover:underline">{{ _('Apri pagina acquisizione') }}</a>
</div> </div>
<div> <div>
@@ -289,14 +310,14 @@
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg 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"> hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
<span x-show="!saving" x-text="isEditing ? {{ _('Salva Modifiche')|tojson_attr }} : {{ _('Crea Stazione')|tojson_attr }}"></span> <span x-show="!saving" x-text="isEditing ? {{ _('Salva Modifiche')|tojson_attr }} : {{ _('Crea Stazione')|tojson_attr }}"></span>
<span x-show="saving" x-cloak>{{ _('Salvataggio...') }}</span> <span x-show="saving">{{ _('Salvataggio...') }}</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal: Recipe Assignments --> <!-- Modal: Recipe Assignments -->
<div x-show="showAssignments" x-cloak <div x-show="showAssignments"
class="fixed inset-0 z-50 flex items-center justify-center p-4" class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeAssignmentsModal()"> @keydown.escape.window="closeAssignmentsModal()">
<div class="absolute inset-0 bg-black/50" @click="closeAssignmentsModal()"></div> <div class="absolute inset-0 bg-black/50" @click="closeAssignmentsModal()"></div>
@@ -417,7 +438,7 @@
</div> </div>
<!-- Confirm Reset Modal --> <!-- Confirm Reset Modal -->
<div x-show="showResetConfirm" x-cloak <div x-show="showResetConfirm"
class="fixed inset-0 z-50 flex items-center justify-center p-4" class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="showResetConfirm = false"> @keydown.escape.window="showResetConfirm = false">
<div class="absolute inset-0 bg-black/50" @click="showResetConfirm = false"></div> <div class="absolute inset-0 bg-black/50" @click="showResetConfirm = false"></div>
@@ -449,7 +470,7 @@
</div> </div>
<!-- Confirm Delete Modal --> <!-- Confirm Delete Modal -->
<div x-show="showDeleteConfirm" x-cloak <div x-show="showDeleteConfirm"
class="fixed inset-0 z-50 flex items-center justify-center p-4"> class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="showDeleteConfirm = false"></div> <div class="absolute inset-0 bg-black/50" @click="showDeleteConfirm = false"></div>
<div x-show="showDeleteConfirm" <div x-show="showDeleteConfirm"
@@ -509,6 +530,12 @@ function stationManagement(initialStations, initialRecipes) {
camera_source: '', // #72 camera_source: '', // #72
active: true, active: true,
}, },
// #88: sorgenti lette dall'acquisitore per il datalist della sorgente camera
cameraSources: [],
cameraSourcesLoaded: false,
cameraSourcesLoading: false,
cameraSourcesStatus: '',
cameraSourcesError: false,
get csrfToken() { get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]'); const meta = document.querySelector('meta[name=csrf-token]');
@@ -556,11 +583,46 @@ function stationManagement(initialStations, initialRecipes) {
return '{{ _("Nessuna ricetta assegnata") }}'; return '{{ _("Nessuna ricetta assegnata") }}';
}, },
// #88: legge le sorgenti dall'acquisitore (proxy admin) e riempie il datalist
async loadCameraSources() {
const url = (this.form.camera_url || '').trim();
this.cameraSources = [];
this.cameraSourcesLoaded = false;
this.cameraSourcesError = false;
if (!url) { this.cameraSourcesStatus = ''; return; }
this.cameraSourcesLoading = true;
this.cameraSourcesStatus = '{{ _("Lettura...") }}';
try {
const resp = await fetch('/admin/api/camera/sources?url=' + encodeURIComponent(url));
const result = await resp.json();
if (!resp.ok) {
this.cameraSourcesError = true;
this.cameraSourcesStatus = result.detail || '{{ _("Risposta non valida dal servizio camera") }}';
return;
}
this.cameraSources = result.sources || [];
this.cameraSourcesLoaded = true;
this.cameraSourcesStatus = '{{ _("%(n)s sorgenti trovate", n="__N__") }}'.replace('__N__', this.cameraSources.length);
} catch (e) {
this.cameraSourcesError = true;
this.cameraSourcesStatus = '{{ _("Risposta non valida dal servizio camera") }}';
} finally {
this.cameraSourcesLoading = false;
}
},
// #88: avviso se la sorgente scritta non e' fra quelle lette
get cameraSourceMissing() {
const src = (this.form.camera_source || '').trim();
return this.cameraSourcesLoaded && src !== '' && !this.cameraSources.some(s => s.name === src);
},
openCreateModal() { openCreateModal() {
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: '', camera_url: '', camera_source: '', active: true }; 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; this.showModal = true;
}, },
@@ -578,6 +640,7 @@ function stationManagement(initialStations, initialRecipes) {
camera_source: station.camera_source || '', // #72 camera_source: station.camera_source || '', // #72
active: station.active, active: station.active,
}; };
this.loadCameraSources(); // #88: se la camera e' valorizzata legge subito le sorgenti
this.showModal = true; this.showModal = true;
}, },
@@ -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) resp = logged_in_client.post("/admin/api/stations", json=body)
assert resp.status_code == 201 assert resp.status_code == 201
mock_admin_api.post.assert_called_once_with("/api/stations", data=body) 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
@@ -120,7 +120,7 @@ class TestStationCameraCalibration:
assert resp.status_code == 200 assert resp.status_code == 200
assert body["state"] == "calibrated" assert body["state"] == "calibrated"
assert body["calibration"]["px_per_mm"] == 20.03 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 args, kwargs = get.call_args
assert args[0] == "http://acq:8765/camera/calibration" assert args[0] == "http://acq:8765/camera/calibration"
assert kwargs["params"] == {"source": "camera:folder"} assert kwargs["params"] == {"source": "camera:folder"}
@@ -130,7 +130,7 @@ class TestStationCameraCalibration:
resp, _ = self._get(logged_in_client, _fake(404, b"{}", {"detail": "no"})) resp, _ = self._get(logged_in_client, _fake(404, b"{}", {"detail": "no"}))
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.get_json()["state"] == "uncalibrated" 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): def test_unreachable(self, logged_in_client, mock_api_client):
_stations(mock_api_client) _stations(mock_api_client)
@@ -3452,3 +3452,36 @@ msgstr "Try"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py #: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Salva ricetta" msgid "Salva ricetta"
msgstr "Save recipe" 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"
@@ -3440,3 +3440,36 @@ msgstr "Prova"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py #: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Salva ricetta" msgid "Salva ricetta"
msgstr "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"