diff --git a/src/frontend/flask_app/blueprints/maker.py b/src/frontend/flask_app/blueprints/maker.py index 574bcf9..a322bcd 100644 --- a/src/frontend/flask_app/blueprints/maker.py +++ b/src/frontend/flask_app/blueprints/maker.py @@ -4,7 +4,9 @@ import math import requests as http_requests -from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for +from functools import wraps + +from flask import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for from flask_babel import gettext as _ from blueprints.auth import login_required, role_required @@ -483,6 +485,173 @@ def api_upload_vision_recipe(task_id: int): return jsonify(resp), 200 +# ============================================================================ +# RICETTA DXF DA CAMERA (#83): stazioni, camera, calibrazione, creazione ricetta +# ============================================================================ + +def maker_or_admin_required(f): + """Maker dell'editor oppure admin della pagina stazioni (#83: stesse rotte camera).""" + @wraps(f) + def decorated(*args, **kwargs): + user = session.get("user", {}) + if not (user.get("is_admin") or "Maker" in user.get("roles", [])): + abort(403) + return f(*args, **kwargs) + return decorated + + +def _stations_for_maker() -> list[dict]: + """Stazioni visibili al chiamante. + + GET /api/stations e' solo admin: un Maker non admin ripiega sulla stazione + corrente (by-code, aperta a tutti), cosi' l'editor funziona comunque. + """ + resp = api_client.get("/api/stations") + if isinstance(resp, list): + return resp + if Config.STATION_CODE: + one = api_client.get_station_by_code(Config.STATION_CODE) + if isinstance(one, dict) and not one.get("error"): + return [one] + return [] + + +def _camera_of_station(station_id: int) -> tuple[str, str] | None: + """(camera_url, camera_source) della stazione con questo id, None se senza camera.""" + for st in _stations_for_maker(): + if st.get("id") == station_id: + if st.get("camera_url") and st.get("camera_source"): + return str(st["camera_url"]), str(st["camera_source"]) + return None + return None + + +@maker_bp.route("/api/stations", methods=["GET"]) +@login_required +@maker_or_admin_required +def api_list_stations_for_maker(): + """Elenco stazioni con camera per il pannello «Crea ricetta da DXF».""" + out = [] + for st in _stations_for_maker(): + if st.get("camera_url") and st.get("camera_source"): + out.append({ + "id": st.get("id"), "code": st.get("code"), "name": st.get("name"), + "camera_url": st.get("camera_url"), + }) + return jsonify(out), 200 + + +# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id +@maker_bp.route("/api/stations//camera/grab", methods=["GET", "POST"]) +@login_required +@maker_or_admin_required +def api_station_camera_grab(station_id: int): + """Proxy: un frame dalla camera della stazione, risposta PNG.""" + camera = _camera_of_station(station_id) + 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") + + +@maker_bp.route("/api/stations//camera/calibration", methods=["GET"]) +@login_required +@maker_or_admin_required +def api_station_camera_calibration(station_id: int): + """Proxy: calibrazione della camera letta dall'acquisitore (C1). + + Risponde sempre 200 con `state`: calibrated | uncalibrated | unreachable, + cosi' la UI mostra lo stato senza trattare «non calibrata» come errore. + 409 solo se la stazione non ha camera. + """ + camera = _camera_of_station(station_id) + if camera is None: + return jsonify({ + "error": True, + "detail": _("Camera non configurata per questa stazione"), + }), 409 + camera_url, camera_source = camera + base = camera_url.rstrip("/") + link = f"{base}/calibrazione" # pagina di calibrazione dell'acquisitore (C4) + try: + resp = http_requests.get( + f"{base}/camera/calibration", + params={"source": camera_source}, + timeout=10, + ) + except http_requests.RequestException as exc: + return jsonify({"state": "unreachable", "detail": str(exc), "calibration_url": link}), 200 + if resp.status_code == 404: + return jsonify({"state": "uncalibrated", "calibration_url": link}), 200 + if resp.status_code != 200: + return jsonify({ + "state": "unreachable", "calibration_url": link, + "detail": _("Risposta %(status)s dall'acquisitore", status=resp.status_code), + }), 200 + try: + calibration = resp.json() + except ValueError: + return jsonify({"state": "unreachable", "calibration_url": link, + "detail": _("Risposta non valida dall'acquisitore")}), 200 + return jsonify({"state": "calibrated", "calibration": calibration, "calibration_url": link}), 200 + + +@maker_bp.route("/api/vision/tasks//recipe/dxf-compare", methods=["POST"]) +@login_required +@role_required("Maker") +def api_dxf_compare_recipe(task_id: int): + """Proxy: crea (o prova, save=false) la ricetta DXF compare da camera (B3).""" + image = request.files.get("image") + dxf = request.files.get("dxf") + if image is None or not image.filename or dxf is None or not dxf.filename: + return jsonify({"error": True, "detail": _("Servono l'immagine e il file DXF")}), 400 + + # Solo i campi del contratto B3; i vuoti non si inoltrano (il backend ha i default) + data = {} + for key in ("station_id", "px_per_mm", "band_mm", "min_points_pct", "polarity", "save"): + value = request.form.get(key) + if value not in (None, ""): + data[key] = value + + resp = api_client.post( + f"/api/vision/tasks/{task_id}/recipe/dxf-compare", + data=data, + files={ + "image": (image.filename, image.read(), image.mimetype or "application/octet-stream"), + "dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream"), + }, + ) + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 200 + + @maker_bp.route("/api/files/", methods=["GET"]) @login_required def api_get_file(file_path: str): diff --git a/src/frontend/flask_app/static/js/dxf_recipe_wizard.js b/src/frontend/flask_app/static/js/dxf_recipe_wizard.js new file mode 100644 index 0000000..5056ee7 --- /dev/null +++ b/src/frontend/flask_app/static/js/dxf_recipe_wizard.js @@ -0,0 +1,181 @@ +// «Crea ricetta da DXF» dell'editor task (#83): da un'immagine della camera e un DXF +// genera la ricetta dxf_compare (POST /maker/api/vision/tasks//recipe/dxf-compare). +// «Prova» (save=false) mostra overlay e quattro valori, «Salva ricetta» (save=true) +// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'. +// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js. +function dxfRecipeWizard(taskId, csrf, msgs) { + // Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found) + const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' }; + const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max']; + + return { + taskId: taskId, + msgs: msgs || {}, + open: false, + stations: [], + stationId: '', + calState: '', // '' | calibrated | uncalibrated | unreachable + calLabel: '', + calUrl: '', + imageFile: null, // immagine caricata o acquisita + imageName: '', + imageSrc: '', + dxfFile: null, + bandMm: 0.1, + minPointsPct: 95, + dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce) + busy: false, + grabbing: false, + error: '', + notice: '', + result: null, // corpo B3 {result, saved, recipe_dir, outputs} + + async toggle() { + this.open = !this.open; + if (this.open && !this.stations.length) await this.loadStations(); + }, + + async loadStations() { + try { + const resp = await fetch('/maker/api/stations'); + if (resp.ok) this.stations = await resp.json(); + } catch (e) { console.error('dxfRecipeWizard stations', e); } + // Una sola stazione con camera: preselezionata + if (this.stations.length === 1) { + this.stationId = String(this.stations[0].id); + await this.onStation(); + } + }, + + // Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore + async onStation() { + this.calState = ''; this.calLabel = ''; this.calUrl = ''; + this.result = null; + if (!this.stationId) return; + const r = await fetchStationCalibration(this.stationId); + this.calState = r.state || 'unreachable'; + this.calUrl = r.calibration_url || ''; + if (this.calState === 'calibrated') this.calLabel = formatCalibration(r.calibration || {}); + }, + + get calibrated() { return this.calState === 'calibrated'; }, + get canRun() { + return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy; + }, + + _setImage(file) { + if (this.imageSrc) URL.revokeObjectURL(this.imageSrc); + this.imageFile = file; + this.imageName = file.name || ''; + this.imageSrc = URL.createObjectURL(file); + this.result = null; + }, + + onImage(ev) { + const f = ev.target.files && ev.target.files[0]; + if (f) this._setImage(f); + }, + onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; }, + + // Frame dalla camera della stazione scelta + async grab() { + if (!this.stationId || this.grabbing) return; + this.grabbing = true; + this.error = ''; + try { + const resp = await fetch(`/maker/api/stations/${this.stationId}/camera/grab`, { + method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, + }); + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + this.error = body.detail || this.msgs.requestFailed || 'Error'; + } else { + const blob = await resp.blob(); + this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' })); + } + } catch (e) { + this.error = this.msgs.requestFailed || String(e); + } + this.grabbing = false; + }, + + _csrf() { return typeof csrf === 'function' ? csrf() : csrf; }, + + // Il 422 porta `detail` (stringa o lista) oppure `errors` + _errorText(body) { + if (Array.isArray(body.errors) && body.errors.length) return body.errors.map(String).join('; '); + const d = body.detail; + if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x)))).join('; '); + if (d && typeof d === 'object') return JSON.stringify(d); + return d || this.msgs.requestFailed || 'Error'; + }, + + async run(save) { + if (!this.canRun) return; + this.busy = true; + this.error = ''; + this.notice = ''; + try { + const fd = new FormData(); + fd.append('image', this.imageFile, this.imageFile.name || 'capture.png'); + fd.append('dxf', this.dxfFile, this.dxfFile.name); + fd.append('station_id', this.stationId); + fd.append('band_mm', String(this.bandMm)); + fd.append('min_points_pct', String(this.minPointsPct)); + fd.append('polarity', this.dark ? 'dark' : 'light'); + fd.append('save', save ? 'true' : 'false'); + const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare`, { + method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd, + }); + const body = await resp.json().catch(() => ({})); + if (!resp.ok || body.error) { + this.error = this._errorText(body); + } else { + this.result = body; + this.$nextTick(() => this.drawOverlay()); + if (save && body.saved) { + this.notice = this.msgs.saved || 'Saved'; + // Il componente visionRecipe ricarica cartella, file e uscite delle quote + this.$dispatch('dxf-recipe-saved', { taskId: this.taskId }); + } + } + } catch (e) { + this.error = this.msgs.requestFailed || String(e); + } + this.busy = false; + }, + + // Le quattro uscite del confronto, nell'ordine fisso + get values() { + const out = (this.result && this.result.result && this.result.result.outputs) || {}; + return VALUE_KEYS.map(k => ({ key: k, value: out[k] })); + }, + + fmt(v) { + return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it', + { maximumFractionDigits: 3 }) : '-'; + }, + + // Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine + drawOverlay() { + const img = this.$refs.img, cv = this.$refs.overlay; + if (!img || !cv || !img.naturalWidth) return; + cv.width = img.naturalWidth; + cv.height = img.naturalHeight; + const g = cv.getContext('2d'); + g.clearRect(0, 0, cv.width, cv.height); + g.lineWidth = Math.max(2, cv.width / 400); + g.lineJoin = 'round'; + const shapes = (this.result && this.result.result && this.result.result.overlay) || []; + for (const item of shapes) { + const rows = item.rows || [], cols = item.cols || []; + if (!rows.length || rows.length !== cols.length) continue; + g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444'; + g.beginPath(); + g.moveTo(cols[0], rows[0]); + for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]); + g.stroke(); + } + }, + }; +} diff --git a/src/frontend/flask_app/static/js/station_calibration.js b/src/frontend/flask_app/static/js/station_calibration.js new file mode 100644 index 0000000..248549e --- /dev/null +++ b/src/frontend/flask_app/static/js/station_calibration.js @@ -0,0 +1,50 @@ +// Stato di calibrazione della camera di una stazione (#83). +// La calibrazione vive nell'acquisitore (camera_service): qui si legge solo lo stato +// tramite il proxy Flask e si offre il link alla sua pagina «Calibra». +// Uso: x-data="stationCalibrationCell(station, msgs)" (msgs: stringhe tradotte). Dipende da Alpine.js. + +// Formatta px/mm e data nella lingua della pagina (es. «20,03 px/mm · 23/10/2026») +function formatCalibration(cal) { + const lang = document.documentElement.lang || 'it'; + let text = new Intl.NumberFormat(lang, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + .format(Number(cal.px_per_mm)) + ' px/mm'; + if (cal.calibrated_at) { + const d = new Date(cal.calibrated_at); + if (!isNaN(d)) text += ' · ' + d.toLocaleDateString(lang); + } + return text; +} + +// Legge lo stato dal proxy: ritorna {state, calibration, calibration_url, detail} +async function fetchStationCalibration(stationId) { + try { + const resp = await fetch(`/maker/api/stations/${stationId}/camera/calibration`); + const body = await resp.json().catch(() => ({})); + if (!resp.ok) return { state: 'unreachable', detail: body.detail || '' }; + return body; + } catch (e) { + return { state: 'unreachable', detail: String(e) }; + } +} + +function stationCalibrationCell(station, msgs) { + return { + hasCamera: !!(station.camera_url && station.camera_source), + cameraUrl: station.camera_url || '', + state: '', // '' = in lettura | calibrated | uncalibrated | unreachable + label: '', + calibrationUrl: '', + msgs: msgs || {}, + + async init() { + 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'); + 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 21a42d6..c004a1d 100644 --- a/src/frontend/flask_app/templates/admin/stations.html +++ b/src/frontend/flask_app/templates/admin/stations.html @@ -50,6 +50,7 @@ {{ _('Nome') }} {{ _('Postazione') }} {{ _('Ricette collegate') }} + {{ _('Calibrazione') }} {{ _('Stato') }} {{ _('Azioni') }} @@ -81,6 +82,25 @@ + {# Calibrazione (#83): stato letto dall'acquisitore della camera, link alla sua pagina «Calibra» #} + + + + + + diff --git a/src/frontend/flask_app/tests/test_dxf_recipe_routes.py b/src/frontend/flask_app/tests/test_dxf_recipe_routes.py new file mode 100644 index 0000000..22c4cd9 --- /dev/null +++ b/src/frontend/flask_app/tests/test_dxf_recipe_routes.py @@ -0,0 +1,232 @@ +"""gitea:Adriano/TieMeasureFlow#83 - ricetta DXF compare da camera: rotte Flask nuove. + +api_client e requests sono finti: backend (B3) e acquisitore (C1) rispondono +come da contratto del piano. +""" +import io +from unittest.mock import MagicMock, patch + +import requests + +from tests.conftest import api_get_router + +STATION = { + "id": 4, "code": "ST-4", "name": "Banco", + "camera_url": "http://acq:8765/", "camera_source": "camera:folder", +} +NO_CAMERA = {"id": 5, "code": "ST-5", "name": "Senza", "camera_url": None, "camera_source": None} +PNG = b"\x89PNG\r\n\x1a\n fake" +CAL = { + "source": "camera:folder", "px_per_mm": 20.03, "mm_per_px": 0.0499, "rms_px": 0.2, + "n_corners": 54, "cols": 9, "rows": 6, "square_mm": 5.0, + "image_width": 2448, "image_height": 2048, "scale_spread_pct": 0.3, + "calibrated_at": "2026-10-23T10:00:00", +} + + +def _fake(status=200, content=PNG, json_body=None): + resp = MagicMock(status_code=status, content=content, text=content.decode("latin-1")) + if json_body is None: + resp.json.side_effect = ValueError("no json") + else: + resp.json.return_value = json_body + return resp + + +def _stations(mock_api_client, stations=(STATION, NO_CAMERA)): + mock_api_client.get.side_effect = api_get_router({"/api/stations": list(stations)}) + + +class TestStationList: + def test_only_stations_with_camera(self, logged_in_client, mock_api_client): + _stations(mock_api_client) + resp = logged_in_client.get("/maker/api/stations") + assert resp.status_code == 200 + assert [s["id"] for s in resp.get_json()] == [4] + + def test_non_admin_list_falls_back_to_current_station( + self, logged_in_client, mock_api_client, monkeypatch): + # /api/stations e' solo admin: il Maker vede la stazione corrente (by-code) + monkeypatch.setattr("blueprints.maker.Config.STATION_CODE", "ST-4") + mock_api_client.get.side_effect = api_get_router( + {"/api/stations": {"error": True, "status_code": 403, "detail": "no"}}) + mock_api_client.get_station_by_code.return_value = STATION + resp = logged_in_client.get("/maker/api/stations") + assert [s["id"] for s in resp.get_json()] == [4] + mock_api_client.get_station_by_code.assert_called_once_with("ST-4") + + def test_requires_maker_or_admin(self, logged_in_client, mock_api_client): + with logged_in_client.session_transaction() as sess: + sess["user"] = {**sess["user"], "roles": ["MeasurementTec"], "is_admin": False} + assert logged_in_client.get("/maker/api/stations").status_code == 403 + + +class TestStationCameraGrab: + def test_png_pass_through(self, logged_in_client, mock_api_client): + _stations(mock_api_client) + with patch("blueprints.maker.http_requests.post", return_value=_fake()) as post: + resp = logged_in_client.post("/maker/api/stations/4/camera/grab") + assert resp.status_code == 200 + assert resp.mimetype == "image/png" + assert resp.data == PNG + args, kwargs = post.call_args + assert args[0] == "http://acq:8765/camera/grab" # slash finale tolto + assert kwargs["json"] == {"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000} + + def test_get_also_works(self, logged_in_client, mock_api_client): + _stations(mock_api_client) + with patch("blueprints.maker.http_requests.post", return_value=_fake()): + assert logged_in_client.get("/maker/api/stations/4/camera/grab").status_code == 200 + + def test_station_without_camera_is_409(self, logged_in_client, mock_api_client): + _stations(mock_api_client) + with patch("blueprints.maker.http_requests.post") as post: + resp = logged_in_client.post("/maker/api/stations/5/camera/grab") + assert resp.status_code == 409 + post.assert_not_called() + + def test_unknown_station_is_409(self, logged_in_client, mock_api_client): + _stations(mock_api_client) + assert logged_in_client.post("/maker/api/stations/99/camera/grab").status_code == 409 + + def test_service_down_is_502(self, logged_in_client, mock_api_client): + _stations(mock_api_client) + with patch("blueprints.maker.http_requests.post", + side_effect=requests.ConnectionError("refused")): + resp = logged_in_client.post("/maker/api/stations/4/camera/grab") + assert resp.status_code == 502 + assert resp.get_json()["error"] + + def test_service_503_is_502_with_detail(self, logged_in_client, mock_api_client): + _stations(mock_api_client) + fake = _fake(503, b"{}", {"detail": "camera occupata"}) + with patch("blueprints.maker.http_requests.post", return_value=fake): + resp = logged_in_client.post("/maker/api/stations/4/camera/grab") + assert resp.status_code == 502 + assert "camera occupata" in resp.get_json()["detail"] + + +class TestStationCameraCalibration: + def _get(self, client, fake=None, exc=None): + with patch("blueprints.maker.http_requests.get", return_value=fake, + side_effect=exc) as get: + resp = client.get("/maker/api/stations/4/camera/calibration") + return resp, get + + def test_calibrated(self, logged_in_client, mock_api_client): + _stations(mock_api_client) + resp, get = self._get(logged_in_client, _fake(200, b"{}", CAL)) + body = resp.get_json() + 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" + args, kwargs = get.call_args + assert args[0] == "http://acq:8765/camera/calibration" + assert kwargs["params"] == {"source": "camera:folder"} + + def test_404_is_uncalibrated(self, logged_in_client, mock_api_client): + _stations(mock_api_client) + 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" + + def test_unreachable(self, logged_in_client, mock_api_client): + _stations(mock_api_client) + resp, _ = self._get(logged_in_client, exc=requests.ConnectionError("down")) + assert resp.status_code == 200 + assert resp.get_json()["state"] == "unreachable" + + def test_server_error_is_unreachable(self, logged_in_client, mock_api_client): + _stations(mock_api_client) + resp, _ = self._get(logged_in_client, _fake(500, b"boom")) + assert resp.get_json()["state"] == "unreachable" + + def test_station_without_camera_is_409(self, logged_in_client, mock_api_client): + _stations(mock_api_client) + with patch("blueprints.maker.http_requests.get") as get: + resp = logged_in_client.get("/maker/api/stations/5/camera/calibration") + assert resp.status_code == 409 + get.assert_not_called() + + +class TestDxfCompareProxy: + URL = "/maker/api/vision/tasks/7/recipe/dxf-compare" + OK = { + "result": {"outputs": {"dxf.points_in_pct": 99.1}, "overlay": [], "outcome": "pass"}, + "saved": False, "recipe_dir": None, "outputs": None, + } + + def _post(self, client, **form): + data = {"image": (io.BytesIO(PNG), "p.png"), "dxf": (io.BytesIO(b"0\nEOF"), "d.dxf"), **form} + return client.post(self.URL, data=data, content_type="multipart/form-data") + + def test_forwards_files_and_fields(self, logged_in_client, mock_api_client): + mock_api_client.post.return_value = self.OK + resp = self._post(logged_in_client, station_id="4", band_mm="0.1", + min_points_pct="95", polarity="dark", save="false", px_per_mm="") + assert resp.status_code == 200 + assert resp.get_json()["result"]["outputs"]["dxf.points_in_pct"] == 99.1 + args, kwargs = mock_api_client.post.call_args + assert args[0] == "/api/vision/tasks/7/recipe/dxf-compare" + # campi del contratto B3; px_per_mm vuoto non si inoltra + assert kwargs["data"] == { + "station_id": "4", "band_mm": "0.1", "min_points_pct": "95", + "polarity": "dark", "save": "false", + } + assert set(kwargs["files"]) == {"image", "dxf"} + assert kwargs["files"]["image"][1] == PNG + + def test_save_true_forwarded(self, logged_in_client, mock_api_client): + mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"} + resp = self._post(logged_in_client, save="true") + assert resp.get_json()["saved"] is True + assert mock_api_client.post.call_args.kwargs["data"]["save"] == "true" + + def test_missing_files_is_400(self, logged_in_client, mock_api_client): + resp = logged_in_client.post(self.URL, data={"station_id": "4"}, + content_type="multipart/form-data") + assert resp.status_code == 400 + mock_api_client.post.assert_not_called() + + def test_backend_422_propagates(self, logged_in_client, mock_api_client): + mock_api_client.post.return_value = { + "error": True, "status_code": 422, "detail": "stazione non calibrata"} + resp = self._post(logged_in_client) + assert resp.status_code == 422 + assert resp.get_json()["detail"] == "stazione non calibrata" + + def test_requires_maker_role(self, logged_in_client, mock_api_client): + with logged_in_client.session_transaction() as sess: + sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]} + assert self._post(logged_in_client).status_code == 403 + mock_api_client.post.assert_not_called() + + +class TestPages: + def test_stations_page_has_calibration_cell(self, logged_in_client): + with patch("blueprints.admin.api_client") as admin_api: + admin_api.get.side_effect = api_get_router({"/api/stations": [STATION], "/api/recipes": []}) + html = logged_in_client.get("/admin/stations").get_data(as_text=True) + assert "js/station_calibration.js" in html + assert "stationCalibrationCell(" in html + # niente piu' pannello di calibrazione nella pagina: solo il link all'acquisitore + assert "calibration-panel" not in html + + def _editor(self, client, mock_api_client, task_type): + mock_api_client.get.side_effect = api_get_router({ + "/api/recipes/3": {"id": 3, "code": "R", "name": "R"}, + "/api/recipes/3/tasks": [{ + "id": 11, "title": "T", "task_type": task_type, "subtasks": [], + "file_path": None, "order_index": 1, + }], + }) + return client.get("/maker/recipes/3/tasks").get_data(as_text=True) + + def test_editor_has_dxf_wizard(self, logged_in_client, mock_api_client): + html = self._editor(logged_in_client, mock_api_client, "dxf_compare") + assert "js/dxf_recipe_wizard.js" in html + assert "dxfRecipeWizard(" in html + assert "@dxf-recipe-saved" in html + assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html 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 ca45c1f..994585b 100644 --- a/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po +++ b/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po @@ -3359,3 +3359,96 @@ msgstr "Camera service unreachable: %(detail)s" #, python-format msgid "Acquisizione non riuscita (%(status)s): %(detail)s" msgstr "Capture failed (%(status)s): %(detail)s" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +#, python-format +msgid "Risposta %(status)s dall'acquisitore" +msgstr "Response %(status)s from the camera service" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Risposta non valida dall'acquisitore" +msgstr "Invalid response from the camera service" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Servono l'immagine e il file DXF" +msgstr "Both the image and the DXF file are required" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Calibrazione" +msgstr "Calibration" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "non calibrata" +msgstr "not calibrated" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "acquisitore non raggiungibile" +msgstr "camera service unreachable" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Nessuna camera" +msgstr "No camera" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Calibra" +msgstr "Calibrate" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Ricetta salvata" +msgstr "Recipe saved" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Crea ricetta da DXF" +msgstr "Create recipe from DXF" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Scegli la stazione" +msgstr "Choose the station" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Nessuna stazione con camera configurata" +msgstr "No station with a camera configured" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Stazione non calibrata: calibrala prima di creare la ricetta." +msgstr "Station not calibrated: calibrate it before creating the recipe." + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione." +msgstr "Camera service unreachable: cannot read the calibration." + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "oppure vedi la pagina Stazioni" +msgstr "or see the Stations page" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Immagine del pezzo" +msgstr "Part image" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Carica immagine" +msgstr "Upload image" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Disegno (.dxf)" +msgstr "Drawing (.dxf)" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Banda ± mm" +msgstr "Band ± mm" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Soglia punti in banda %%" +msgstr "Points-in-band threshold %%" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Pezzo scuro su fondo chiaro (controluce)" +msgstr "Dark part on light background (backlight)" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Prova" +msgstr "Try" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Salva ricetta" +msgstr "Save recipe" 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 b292813..ae14ee7 100644 --- a/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po +++ b/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po @@ -3347,3 +3347,96 @@ msgstr "Servizio camera non raggiungibile: %(detail)s" #, python-format msgid "Acquisizione non riuscita (%(status)s): %(detail)s" msgstr "Acquisizione non riuscita (%(status)s): %(detail)s" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +#, python-format +msgid "Risposta %(status)s dall'acquisitore" +msgstr "Risposta %(status)s dall'acquisitore" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Risposta non valida dall'acquisitore" +msgstr "Risposta non valida dall'acquisitore" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Servono l'immagine e il file DXF" +msgstr "Servono l'immagine e il file DXF" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Calibrazione" +msgstr "Calibrazione" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "non calibrata" +msgstr "non calibrata" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "acquisitore non raggiungibile" +msgstr "acquisitore non raggiungibile" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Nessuna camera" +msgstr "Nessuna camera" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Calibra" +msgstr "Calibra" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Ricetta salvata" +msgstr "Ricetta salvata" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Crea ricetta da DXF" +msgstr "Crea ricetta da DXF" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Scegli la stazione" +msgstr "Scegli la stazione" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Nessuna stazione con camera configurata" +msgstr "Nessuna stazione con camera configurata" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Stazione non calibrata: calibrala prima di creare la ricetta." +msgstr "Stazione non calibrata: calibrala prima di creare la ricetta." + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione." +msgstr "Acquisitore non raggiungibile: impossibile leggere la calibrazione." + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "oppure vedi la pagina Stazioni" +msgstr "oppure vedi la pagina Stazioni" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Immagine del pezzo" +msgstr "Immagine del pezzo" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Carica immagine" +msgstr "Carica immagine" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Disegno (.dxf)" +msgstr "Disegno (.dxf)" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Banda ± mm" +msgstr "Banda ± mm" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Soglia punti in banda %%" +msgstr "Soglia punti in banda %%" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Pezzo scuro su fondo chiaro (controluce)" +msgstr "Pezzo scuro su fondo chiaro (controluce)" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Prova" +msgstr "Prova" + +#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py +msgid "Salva ricetta" +msgstr "Salva ricetta"