From e640c4a36e1a57a048982feba3f33c27dd20c691 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 11:55:31 +0200 Subject: [PATCH] Maker: ricetta visione e uscita per quota, tipi camera_measure/dxf_compare (#46) Co-Authored-By: Claude Sonnet 5.5 --- src/frontend/flask_app/blueprints/maker.py | 41 +++++++++ .../flask_app/static/js/vision_recipe.js | 80 +++++++++++++++++ .../templates/maker/task_editor.html | 87 +++++++++++++++++-- src/frontend/flask_app/tests/test_maker.py | 79 +++++++++++++++++ 4 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 src/frontend/flask_app/static/js/vision_recipe.js diff --git a/src/frontend/flask_app/blueprints/maker.py b/src/frontend/flask_app/blueprints/maker.py index 9932978..0080ee4 100644 --- a/src/frontend/flask_app/blueprints/maker.py +++ b/src/frontend/flask_app/blueprints/maker.py @@ -377,6 +377,47 @@ def api_upload_file(): return jsonify(resp), 201 +# ============================================================================ +# VISION RECIPE PROXY (#46) +# ============================================================================ + +@maker_bp.route("/api/vision/tasks//recipe", methods=["GET"]) +@login_required +@role_required("Maker") +def api_get_vision_recipe(task_id: int): + """Proxy: ricetta visione corrente del task (cartella, file, uscite).""" + resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe") + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + return jsonify(resp), 200 + + +@maker_bp.route("/api/vision/tasks//recipe", methods=["POST"]) +@login_required +@role_required("Maker") +def api_upload_vision_recipe(task_id: int): + """Proxy: carica ricetta .json + file allegati (multipart forward).""" + recipe = request.files.get("recipe") + if recipe is None or recipe.filename == "": + return jsonify({"error": True, "detail": _("Nessun file ricetta caricato")}), 400 + + # Ricetta + 0..n allegati (DXF, modelli .npz, calibrazione), stesso nome campo `files` + files = [("recipe", (recipe.filename, recipe.stream, recipe.content_type))] + for f in request.files.getlist("files"): + if f.filename: + files.append(("files", (f.filename, f.stream, f.content_type))) + + resp = api_client.post(f"/api/vision/tasks/{task_id}/recipe", data={}, files=files) + + # Il 422 (errori di validazione) arriva al browser com'รจ, con `errors` + 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/vision_recipe.js b/src/frontend/flask_app/static/js/vision_recipe.js new file mode 100644 index 0000000..1258268 --- /dev/null +++ b/src/frontend/flask_app/static/js/vision_recipe.js @@ -0,0 +1,80 @@ +// Ricetta visione del Maker (#46): componente Alpine per i task camera_measure / dxf_compare. +// Carica la ricetta VisionSuite v2 + i file allegati verso /maker/api/vision/tasks//recipe, +// mostra cartella, file e uscite numeriche; notifica le uscite al task editor con un evento. +// Uso: x-data="visionRecipe(task.id, csrfToken, msgs)". Dipende da Alpine.js. +function visionRecipe(taskId, csrf, msgs) { + return { + taskId: taskId, + recipeDir: null, + files: [], + outputs: [], + errors: [], // errori 422 elencati in chiaro + loading: false, + busy: false, + recipeFile: null, + attachments: [], + msgs: msgs || {}, + + async init() { + // All'apertura: ricetta corrente (GET); il 404/errore non blocca l'editor + this.loading = true; + try { + const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`); + if (resp.ok) this._apply(await resp.json()); + } catch (e) { console.error('visionRecipe init', e); } + this.loading = false; + }, + + // Aggiorna lo stato e avvisa il task editor (select vision_output delle quote) + _apply(data) { + this.recipeDir = data.recipe_dir || null; + this.files = data.files || []; + this.outputs = data.outputs || []; + window.dispatchEvent(new CustomEvent('vision-outputs', { + detail: { taskId: this.taskId, outputs: this.outputs } + })); + }, + + // Il 422 porta `errors` (lista) oppure `detail` (stringa o lista di voci) + _errorsFrom(data) { + if (Array.isArray(data.errors) && data.errors.length) return data.errors.map(String); + const d = data.detail; + if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x)))); + if (d && typeof d === 'object') { + if (Array.isArray(d.errors)) return d.errors.map(String); + return [JSON.stringify(d)]; + } + return [d || this.msgs.genericError || 'Error']; + }, + + async upload() { + if (!this.recipeFile) return; + this.busy = true; + this.errors = []; + try { + const fd = new FormData(); + fd.append('recipe', this.recipeFile); + for (const f of this.attachments) fd.append('files', f); + const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`, { + method: 'POST', + headers: { 'X-CSRFToken': typeof csrf === 'function' ? csrf() : csrf }, + body: fd + }); + const data = await resp.json(); + if (!resp.ok || data.error) { + this.errors = this._errorsFrom(data); + } else { + this._apply(data); + this.recipeFile = null; + this.attachments = []; + } + } catch (e) { + this.errors = [this.msgs.genericError || String(e)]; + } + this.busy = false; + }, + + onRecipe(ev) { this.recipeFile = ev.target.files[0] || null; }, + onAttachments(ev) { this.attachments = Array.from(ev.target.files || []); } + }; +} diff --git a/src/frontend/flask_app/templates/maker/task_editor.html b/src/frontend/flask_app/templates/maker/task_editor.html index 1ed954d..a73532e 100644 --- a/src/frontend/flask_app/templates/maker/task_editor.html +++ b/src/frontend/flask_app/templates/maker/task_editor.html @@ -277,6 +277,8 @@ + +

{{ _('Un task di misura resta tale anche prima di inserire le quote') }} @@ -460,6 +462,55 @@

+ + +

@@ -543,6 +594,8 @@ + +

@@ -699,6 +752,18 @@ class="tmf-input" placeholder="{{ _('Es. Diametro foro principale') }}">
+ +
+ + +
@@ -1093,6 +1158,7 @@ {% block extra_js %} {# Bold and line breaks in descriptions: the same rules the server renders with. #} + @@ -1129,8 +1195,11 @@ function taskEditor() { addingSubtaskForTask: null, newSubtask: { marker_number: 1, description: '', measurement_type: '', - nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm' + nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm', + vision_output: '' }, + // Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js + visionOutputs: {}, // ---- Delete modals ---- deleteTaskModal: false, @@ -1153,6 +1222,10 @@ function taskEditor() { // Init // ============================================================ init() { + // Uscite della ricetta visione: evento dal componente visionRecipe (#46) + window.addEventListener('vision-outputs', (ev) => { + this.visionOutputs = { ...this.visionOutputs, [ev.detail.taskId]: ev.detail.outputs || [] }; + }); // Sort tasks by order_index on load this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0)); @@ -1610,7 +1683,8 @@ function taskEditor() { uwl: null, lwl: null, ltl: null, - unit: 'mm' + unit: 'mm', + vision_output: '' }; }, @@ -1634,7 +1708,8 @@ function taskEditor() { uwl: this.newSubtask.uwl, lwl: this.newSubtask.lwl, ltl: this.newSubtask.ltl, - unit: this.newSubtask.unit || 'mm' + unit: this.newSubtask.unit || 'mm', + vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46) }; try { @@ -1687,7 +1762,8 @@ function taskEditor() { uwl: subtask.uwl, lwl: subtask.lwl, ltl: subtask.ltl, - unit: subtask.unit || 'mm' + unit: subtask.unit || 'mm', + vision_output: subtask.vision_output || '' }; }, @@ -1711,7 +1787,8 @@ function taskEditor() { uwl: this.newSubtask.uwl, lwl: this.newSubtask.lwl, ltl: this.newSubtask.ltl, - unit: this.newSubtask.unit || 'mm' + unit: this.newSubtask.unit || 'mm', + vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46) }; try { diff --git a/src/frontend/flask_app/tests/test_maker.py b/src/frontend/flask_app/tests/test_maker.py index 15e18a1..544fc4e 100644 --- a/src/frontend/flask_app/tests/test_maker.py +++ b/src/frontend/flask_app/tests/test_maker.py @@ -161,3 +161,82 @@ class TestVersionConflictProxy: assert resp.status_code == 409 assert resp.get_json()["detail"] == self._CONFLICT["detail"] + + +class TestVisionRecipeProxy: + """Proxy ricetta visione (#46): /maker/api/vision/tasks//recipe.""" + + def test_upload_forwards_recipe_and_files(self, logged_in_client, mock_api_client): + import io + + mock_api_client.post.return_value = { + "task_id": 7, "recipe_dir": "vision/recipes/7/abc", "files": ["a.dxf"], + "outputs": [{"key": "n1.points_in_pct", "quantity": None, "unit": None}], + } + + resp = logged_in_client.post( + "/maker/api/vision/tasks/7/recipe", + data={ + "recipe": (io.BytesIO(b"{}"), "r.json"), + "files": [(io.BytesIO(b"dxf"), "a.dxf"), (io.BytesIO(b"npz"), "m.npz")], + }, + content_type="multipart/form-data", + ) + + assert resp.status_code == 200 + assert resp.get_json()["outputs"][0]["key"] == "n1.points_in_pct" + endpoint = mock_api_client.post.call_args.args[0] + sent = mock_api_client.post.call_args.kwargs["files"] + assert endpoint == "/api/vision/tasks/7/recipe" + # un campo `recipe` e un campo `files` per ogni allegato + assert [name for name, _f in sent] == ["recipe", "files", "files"] + + def test_upload_without_recipe_is_400(self, logged_in_client, mock_api_client): + resp = logged_in_client.post( + "/maker/api/vision/tasks/7/recipe", data={}, content_type="multipart/form-data" + ) + assert resp.status_code == 400 + mock_api_client.post.assert_not_called() + + def test_upload_proxies_422(self, logged_in_client, mock_api_client): + import io + + mock_api_client.post.return_value = { + "error": True, "status_code": 422, "detail": "file mancante: a.dxf", + } + resp = logged_in_client.post( + "/maker/api/vision/tasks/7/recipe", + data={"recipe": (io.BytesIO(b"{}"), "r.json")}, + content_type="multipart/form-data", + ) + assert resp.status_code == 422 + assert "a.dxf" in resp.get_json()["detail"] + + def test_get_recipe_proxies(self, logged_in_client, mock_api_client): + mock_api_client.get.return_value = { + "task_id": 7, "recipe_dir": None, "recipe": None, "files": [], "outputs": [], + } + resp = logged_in_client.get("/maker/api/vision/tasks/7/recipe") + assert resp.status_code == 200 + mock_api_client.get.assert_called_once_with("/api/vision/tasks/7/recipe") + + +class TestTaskEditorVisionUi: + """task_editor.html espone i tipi visione e la select vision_output (#46).""" + + def test_editor_has_vision_types_and_section(self, logged_in_client, mock_api_client): + from tests.conftest import api_get_router + + 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": "dxf_compare", "subtasks": [], + "file_path": None, "order_index": 1, + }], + }) + html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True) + assert 'value="camera_measure"' in html + assert 'value="dxf_compare"' in html + assert "visionRecipe(" in html + assert "vision_output" in html + assert "js/vision_recipe.js" in html