Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e640c4a36e |
@@ -377,6 +377,47 @@ def api_upload_file():
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# VISION RECIPE PROXY (#46)
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/api/vision/tasks/<int:task_id>/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/<int:task_id>/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/<path:file_path>", methods=["GET"])
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||
import urllib.parse
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from flask import (
|
||||
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
||||
Blueprint, abort, flash, jsonify, redirect, render_template,
|
||||
request, session, url_for,
|
||||
)
|
||||
from flask_babel import gettext as _
|
||||
@@ -822,67 +820,3 @@ def api_close_production(run_id: int):
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
return proxy_file(file_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: vision execute (#46) - the browser sends an image, we relay it to
|
||||
# POST /api/vision/execute (multipart) because it cannot send X-API-Key itself.
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/api/vision/execute", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_execute():
|
||||
"""Proxy: run the task's VisionSuite recipe on the uploaded image."""
|
||||
image = request.files.get("image")
|
||||
task_id = request.form.get("task_id")
|
||||
if image is None or not task_id:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
|
||||
}), 400
|
||||
|
||||
# Same context fields as the manual flow; empty ones are left out so the
|
||||
# backend sees them as absent rather than as "".
|
||||
form = {"task_id": task_id}
|
||||
for key in ("task_type", "lot_number", "serial_number", "production_run_id"):
|
||||
val = request.form.get(key)
|
||||
if val:
|
||||
form[key] = val
|
||||
form.setdefault("lot_number", session.get("lot_number", ""))
|
||||
form.setdefault("serial_number", session.get("serial_number", ""))
|
||||
|
||||
resp = api_client.post(
|
||||
"/api/vision/execute",
|
||||
data=form,
|
||||
files={"image": (
|
||||
image.filename or "capture.jpg",
|
||||
image.read(),
|
||||
image.mimetype or "application/octet-stream",
|
||||
)},
|
||||
)
|
||||
if resp.get("error"):
|
||||
status_code = resp.get("status_code", 500)
|
||||
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
|
||||
if resp.get("session_expired"):
|
||||
body["session_expired"] = True
|
||||
return jsonify(body), status_code if status_code >= 400 else 500
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_result_image(result_id: int):
|
||||
"""Proxy: the image saved with a vision result (needs X-API-Key)."""
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/vision/results/{result_id}/image",
|
||||
headers={"X-API-Key": session.get("api_key", "")},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return Response(resp.text, status=resp.status_code)
|
||||
return Response(
|
||||
resp.content,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* Vision execute - Alpine component for camera_measure / dxf_compare tasks (#46).
|
||||
*
|
||||
* The operator takes (or picks) an image, `acquire(blob)` sends it to the Flask
|
||||
* proxy, and the result - quote table, outcome, failures, overlay - is shown on
|
||||
* the image. `acquire(blob)` is the single entry point on purpose: the agent
|
||||
* camera (#3) will call it with its own blob and reuse everything below.
|
||||
*
|
||||
* Config comes from window.__visionCfg (set by the template):
|
||||
* executeUrl, imageUrlTpl (with a 0 placeholder for the result id), taskId,
|
||||
* taskType, subtasks, labels.
|
||||
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
||||
* which the template binds to the parent task component.
|
||||
*/
|
||||
function visionExecute() {
|
||||
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||
|
||||
return {
|
||||
busy: false,
|
||||
error: '',
|
||||
result: null, // last execute response
|
||||
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
|
||||
|
||||
// Entry point shared with the future agent camera.
|
||||
async acquire(blob) {
|
||||
if (!blob || this.busy) return;
|
||||
const cfg = window.__visionCfg || {};
|
||||
this.busy = true;
|
||||
this.error = '';
|
||||
this.result = null;
|
||||
// Show the picked image at once: the overlay is drawn on top when the result arrives.
|
||||
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||
this.imageSrc = URL.createObjectURL(blob);
|
||||
try {
|
||||
const c = this.ctx() || {};
|
||||
const fd = new FormData();
|
||||
fd.append('task_id', cfg.taskId);
|
||||
fd.append('task_type', cfg.taskType || '');
|
||||
fd.append('image', blob, blob.name || 'capture.jpg');
|
||||
fd.append('lot_number', c.lot || '');
|
||||
fd.append('serial_number', c.serial || '');
|
||||
if (c.runId) fd.append('production_run_id', c.runId);
|
||||
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const resp = await fetch(cfg.executeUrl, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || body.error) {
|
||||
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
}
|
||||
this.result = body;
|
||||
// Draw once Alpine has rendered the canvas.
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
} catch (e) {
|
||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Input change: take the file and allow picking the same one again.
|
||||
onPick(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
if (f) this.acquire(f);
|
||||
},
|
||||
|
||||
// Image shown: the saved one from the server when available, else the local one.
|
||||
get shownImage() {
|
||||
const cfg = window.__visionCfg || {};
|
||||
if (this.result && this.result.vision_result_id && cfg.imageUrlTpl) {
|
||||
return cfg.imageUrlTpl.replace('/0/', '/' + this.result.vision_result_id + '/');
|
||||
}
|
||||
return this.imageSrc;
|
||||
},
|
||||
|
||||
// Quote rows: server measurements joined with the subtask names.
|
||||
get rows() {
|
||||
const cfg = window.__visionCfg || {};
|
||||
const subs = cfg.subtasks || [];
|
||||
return ((this.result && this.result.measurements) || []).map((m) => {
|
||||
const st = subs.find((s) => s.id === m.subtask_id) || {};
|
||||
return {
|
||||
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
|
||||
value: m.value,
|
||||
unit: st.unit || 'mm',
|
||||
status: m.pass_fail,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
statusClass(s) {
|
||||
// Same palette as the measurement markers of the manual flow.
|
||||
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
|
||||
if (s === 'fail') return 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40';
|
||||
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
|
||||
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
|
||||
},
|
||||
|
||||
// Canvas is sized to the natural image, so overlay coordinates (image px)
|
||||
// are used as they are and CSS scales the whole thing to the displayed size.
|
||||
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';
|
||||
for (const item of ((this.result && this.result.overlay) || [])) {
|
||||
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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/<id>/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 || []); }
|
||||
};
|
||||
}
|
||||
@@ -277,6 +277,8 @@
|
||||
<option value="note">{{ _('Nota') }}</option>
|
||||
<option value="measure">{{ _('Misura') }}</option>
|
||||
<option value="drawing">{{ _('Disegno') }}</option>
|
||||
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
||||
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
||||
@@ -460,6 +462,55 @@
|
||||
|
||||
<div class="tmf-card-body space-y-4">
|
||||
|
||||
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
|
||||
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
|
||||
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
|
||||
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||
{{ _('Ricetta visione') }}
|
||||
</p>
|
||||
<!-- Stato corrente: cartella, file, uscite -->
|
||||
<template x-if="recipeDir">
|
||||
<div class="text-xs space-y-1 mb-3">
|
||||
<p><span class="text-[var(--text-secondary)]">{{ _('Cartella') }}:</span>
|
||||
<span class="font-mono" x-text="recipeDir"></span></p>
|
||||
<p x-show="files.length"><span class="text-[var(--text-secondary)]">{{ _('File') }}:</span>
|
||||
<span class="font-mono" x-text="files.join(', ')"></span></p>
|
||||
<p x-show="outputs.length"><span class="text-[var(--text-secondary)]">{{ _('Uscite numeriche') }}:</span></p>
|
||||
<ul class="list-disc pl-5" x-show="outputs.length">
|
||||
<template x-for="o in outputs" :key="o.key">
|
||||
<li><span class="font-mono" x-text="o.key"></span>
|
||||
<span class="text-[var(--text-muted)]" x-text="(o.quantity || o.unit) ? '(' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : ''"></span></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!recipeDir && !loading" class="text-xs text-[var(--text-muted)] mb-3">
|
||||
{{ _('Nessuna ricetta caricata') }}
|
||||
</p>
|
||||
<!-- Caricamento: ricetta .json + allegati -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Ricetta (.json)') }}</label>
|
||||
<input type="file" accept=".json,application/json" class="tmf-input text-xs"
|
||||
@change="onRecipe($event)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('File allegati (DXF, modelli, calibrazione)') }}</label>
|
||||
<input type="file" multiple class="tmf-input text-xs" @change="onAttachments($event)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-3">
|
||||
<button type="button" class="btn btn-primary text-xs" @click="upload()"
|
||||
:disabled="!recipeFile || busy">{{ _('Carica ricetta') }}</button>
|
||||
</div>
|
||||
<!-- Errori 422 in chiaro -->
|
||||
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
|
||||
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ========== Task File Upload / Preview ========== -->
|
||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
||||
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||
@@ -543,6 +594,8 @@
|
||||
<option value="note">{{ _('Nota') }}</option>
|
||||
<option value="measure">{{ _('Misura') }}</option>
|
||||
<option value="drawing">{{ _('Disegno') }}</option>
|
||||
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -699,6 +752,18 @@
|
||||
class="tmf-input"
|
||||
placeholder="{{ _('Es. Diametro foro principale') }}">
|
||||
</div>
|
||||
<!-- Uscita visione (#46): solo task camera_measure / dxf_compare -->
|
||||
<div class="col-span-2 sm:col-span-3 lg:col-span-2"
|
||||
x-show="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
||||
<label class="tmf-label text-xs">{{ _('Uscita visione') }}</label>
|
||||
<select x-model="newSubtask.vision_output" class="tmf-input">
|
||||
<option value="">{{ _('Nessuna') }}</option>
|
||||
<template x-for="o in (visionOutputs[task.id] || [])" :key="o.key">
|
||||
<option :value="o.key" :selected="o.key === newSubtask.vision_output"
|
||||
x-text="o.key + ((o.quantity || o.unit) ? ' (' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : '')"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Measurement Type -->
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
||||
@@ -1093,6 +1158,7 @@
|
||||
{% block extra_js %}
|
||||
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
||||
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
||||
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
@@ -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 {
|
||||
|
||||
@@ -191,8 +191,7 @@
|
||||
{# ================================================================
|
||||
MAIN — 3-column layout
|
||||
================================================================ #}
|
||||
{# #46: camera_measure / dxf_compare get the acquisition panel below instead of the manual layout #}
|
||||
<div class="flex-1 flex overflow-hidden" x-show="!isVisionTask">
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
|
||||
@@ -452,63 +451,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
VISION PANEL (#46) - acquire an image, run the recipe server-side, show the
|
||||
quotes and the overlay. acquire(blob) is the entry point the agent camera
|
||||
(#3) will reuse.
|
||||
================================================================ #}
|
||||
<div x-show="isVisionTask" x-cloak
|
||||
x-data="visionExecute()"
|
||||
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
|
||||
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
||||
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
|
||||
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
|
||||
<input type="file" accept="image/*" capture="environment" class="hidden"
|
||||
@change="onPick($event)" :disabled="busy">
|
||||
</label>
|
||||
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
||||
</div>
|
||||
|
||||
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
|
||||
|
||||
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
||||
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||
</div>
|
||||
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
||||
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
|
||||
<div class="text-sm font-bold text-[var(--text-primary)]">
|
||||
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm" x-show="rows.length">
|
||||
<template x-for="r in rows" :key="r.label">
|
||||
<tr>
|
||||
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
|
||||
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
|
||||
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
|
||||
<div x-show="result && result.failures && result.failures.length"
|
||||
class="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-xs">
|
||||
<div class="font-bold mb-1">{{ _('Strumenti falliti') }}</div>
|
||||
<template x-for="f in ((result && result.failures) || [])" :key="f.tool_id">
|
||||
<div><span class="font-mono" x-text="f.tool_name || f.tool_id"></span>: <span x-text="f.error"></span></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
MEASUREMENT TIMER BANNER
|
||||
================================================================ #}
|
||||
@@ -970,18 +912,6 @@
|
||||
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
|
||||
{# #46: acquisition + overlay for camera_measure / dxf_compare #}
|
||||
<script>
|
||||
window.__visionCfg = {
|
||||
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
||||
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||
taskId: {{ task.id|tojson }},
|
||||
taskType: {{ (task.task_type or '')|tojson }},
|
||||
subtasks: window.__taskSubtasks,
|
||||
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
||||
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
||||
measurement lives - are shared with the other screens rather than restated. #}
|
||||
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
||||
@@ -1846,12 +1776,6 @@ function taskExecute() {
|
||||
return declared === 'measure' || declared === 'camera_measure';
|
||||
},
|
||||
|
||||
/* #46: image-based tasks run a VisionSuite recipe on the server. */
|
||||
get isVisionTask() {
|
||||
const t = this.task.task_type;
|
||||
return t === 'camera_measure' || t === 'dxf_compare';
|
||||
},
|
||||
|
||||
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
||||
loud rather than silently rendering an empty screen. */
|
||||
get isMeasureTaskWithoutQuotes() {
|
||||
|
||||
@@ -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/<id>/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
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"""gitea:Adriano/TieMeasureFlow#46 - operator side of the vision tasks.
|
||||
|
||||
The backend of contract B is not merged yet: api_client is mocked.
|
||||
"""
|
||||
import io
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.conftest import api_get_router
|
||||
|
||||
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 test_execute_page_has_acquire_input(logged_in_client, mock_api_client, monkeypatch):
|
||||
"""camera_measure screen offers the file/camera input and the acquire entry point."""
|
||||
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||
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,
|
||||
})
|
||||
html = logged_in_client.get("/measure/execute/12").get_data(as_text=True)
|
||||
assert 'capture="environment"' in html
|
||||
assert 'accept="image/*"' in html
|
||||
assert "visionExecute()" in html
|
||||
assert "/measure/api/vision/execute" in html
|
||||
|
||||
|
||||
def test_vision_execute_proxy_forwards_multipart(logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = {
|
||||
"vision_result_id": 7, "outcome": "ok", "overlay": [], "failures": [],
|
||||
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
|
||||
}
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/vision/execute",
|
||||
data={
|
||||
"task_id": "12", "lot_number": "L1", "serial_number": "S1",
|
||||
"production_run_id": "4",
|
||||
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["vision_result_id"] == 7
|
||||
args, kwargs = mock_api_client.post.call_args
|
||||
assert args[0] == "/api/vision/execute"
|
||||
assert kwargs["data"]["task_id"] == "12"
|
||||
assert kwargs["data"]["lot_number"] == "L1"
|
||||
assert kwargs["data"]["production_run_id"] == "4"
|
||||
name, content, mime = kwargs["files"]["image"]
|
||||
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
|
||||
|
||||
|
||||
def test_vision_execute_proxy_requires_image(logged_in_client, mock_api_client):
|
||||
resp = logged_in_client.post("/measure/api/vision/execute", data={"task_id": "12"})
|
||||
assert resp.status_code == 400
|
||||
mock_api_client.post.assert_not_called()
|
||||
|
||||
|
||||
def test_vision_execute_proxy_relays_backend_error(logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "bad"}
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/vision/execute",
|
||||
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.get_json()["detail"] == "bad"
|
||||
|
||||
|
||||
def test_vision_result_image_proxy(logged_in_client):
|
||||
fake = MagicMock(status_code=200, content=b"IMG", headers={"content-type": "image/jpeg"})
|
||||
with patch("blueprints.measure.http_requests.get", return_value=fake) as g:
|
||||
resp = logged_in_client.get("/measure/api/vision/results/7/image")
|
||||
assert resp.status_code == 200 and resp.data == b"IMG"
|
||||
assert g.call_args[0][0].endswith("/api/vision/results/7/image")
|
||||
assert g.call_args[1]["headers"]["X-API-Key"] == "test-api-key-12345"
|
||||
Reference in New Issue
Block a user