Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5aa19170e | |||
| 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):
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* image-crop.js — ritaglio di immagine/PDF prima dell'upload (#56).
|
||||
*
|
||||
* Cosa fa: ImageCrop.open(file) apre una modale con un rettangolo di ritaglio
|
||||
* trascinabile e ridimensionabile (mouse e touch, Pointer Events). Per un PDF
|
||||
* rasterizza la pagina scelta con pdf.js (>= 2000 px sul lato lungo).
|
||||
* Risolve: Blob PNG del ritaglio (a piena risoluzione) | il file originale
|
||||
* ("Usa originale") | null ("Annulla").
|
||||
* Come si usa: ImageCrop.configure({pdfSrc, workerSrc, labels}) una volta dal
|
||||
* template (URL di vendor/pdf e stringhe tradotte), poi `await ImageCrop.open(file)`.
|
||||
* Dipendenze: nessuna obbligatoria. pdf.js (gia' vendorizzato) viene caricato
|
||||
* al bisogno solo per i PDF; il ritaglio usa un canvas 2D semplice invece di
|
||||
* fabric.js perche' serve solo un rettangolo, non un editor di oggetti.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var cfg = {
|
||||
pdfSrc: '',
|
||||
workerSrc: '',
|
||||
labels: {
|
||||
title: 'Ritaglia immagine',
|
||||
page: 'Pagina',
|
||||
hint: 'Trascina il rettangolo o i suoi bordi per scegliere la zona da tenere.',
|
||||
pdfNote: 'Un PDF ritagliato viene salvato come immagine PNG.',
|
||||
confirm: 'Conferma ritaglio',
|
||||
original: 'Usa originale',
|
||||
cancel: 'Annulla',
|
||||
loading: 'Caricamento...',
|
||||
error: 'Impossibile aprire il file per il ritaglio.'
|
||||
}
|
||||
};
|
||||
|
||||
var MIN_SIDE = 16; // lato minimo del ritaglio, in px immagine
|
||||
var HANDLE = 12; // raggio di presa delle maniglie, in px schermo
|
||||
var PDF_MIN_LONG = 2000; // lato lungo minimo del raster PDF
|
||||
var PDF_MAX_LONG = 6000; // tetto per non esaurire la memoria del canvas
|
||||
|
||||
function configure(opts) {
|
||||
if (!opts) return;
|
||||
if (opts.pdfSrc) cfg.pdfSrc = opts.pdfSrc;
|
||||
if (opts.workerSrc) cfg.workerSrc = opts.workerSrc;
|
||||
if (opts.labels) for (var k in opts.labels) cfg.labels[k] = opts.labels[k];
|
||||
}
|
||||
|
||||
function isPdf(file) {
|
||||
return file.type === 'application/pdf' || /\.pdf$/i.test(file.name || '');
|
||||
}
|
||||
|
||||
function loadPdfJs() {
|
||||
if (global.pdfjsLib) return Promise.resolve(global.pdfjsLib);
|
||||
if (!cfg.pdfSrc) return Promise.reject(new Error('pdf.js non configurato'));
|
||||
return new Promise(function (resolve, reject) {
|
||||
var s = document.createElement('script');
|
||||
s.src = cfg.pdfSrc;
|
||||
s.onload = function () { resolve(global.pdfjsLib); };
|
||||
s.onerror = function () { reject(new Error('pdf.js non caricato')); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
function readImage(file) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var url = URL.createObjectURL(file);
|
||||
var img = new Image();
|
||||
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
|
||||
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('immagine illeggibile')); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function renderPdfPage(pdf, n) {
|
||||
return pdf.getPage(n).then(function (page) {
|
||||
var base = page.getViewport({ scale: 1 });
|
||||
var long = Math.max(base.width, base.height);
|
||||
var scale = Math.min(PDF_MAX_LONG / long, Math.max(2, PDF_MIN_LONG / long));
|
||||
var vp = page.getViewport({ scale: scale });
|
||||
var c = document.createElement('canvas');
|
||||
c.width = Math.round(vp.width);
|
||||
c.height = Math.round(vp.height);
|
||||
return page.render({ canvasContext: c.getContext('2d'), viewport: vp }).promise
|
||||
.then(function () { return c; });
|
||||
});
|
||||
}
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
function open(file) {
|
||||
var L = cfg.labels;
|
||||
return new Promise(function (resolve) {
|
||||
var src = null; // Image o canvas sorgente a piena risoluzione
|
||||
var rect = null; // {x,y,w,h} in px immagine
|
||||
var pdfDoc = null;
|
||||
var closed = false;
|
||||
|
||||
// ---- DOM (stile Maker: variabili CSS del tema + classi btn gia' presenti) ----
|
||||
var overlay = el('div', 'fixed inset-0 z-[100] flex items-center justify-center p-3 bg-black/60');
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
var box = el('div', 'rounded-xl shadow-xl flex flex-col w-full max-w-4xl max-h-full');
|
||||
box.style.cssText = 'background:var(--bg-primary,#fff);color:var(--text-primary,#111);border:1px solid var(--border-color,#ddd);';
|
||||
var head = el('div', 'flex items-center justify-between gap-3 px-4 py-3');
|
||||
head.appendChild(el('h3', 'font-semibold text-sm', L.title));
|
||||
var pageWrap = el('label', 'text-xs items-center gap-2');
|
||||
pageWrap.style.display = 'none';
|
||||
pageWrap.appendChild(document.createTextNode(L.page + ' '));
|
||||
var pageSel = el('select', 'tmf-input text-xs');
|
||||
pageSel.style.width = 'auto';
|
||||
pageWrap.appendChild(pageSel);
|
||||
head.appendChild(pageWrap);
|
||||
var body = el('div', 'px-4 flex-1 min-h-0 overflow-auto flex justify-center');
|
||||
var status = el('p', 'text-xs py-6', L.loading);
|
||||
body.appendChild(status);
|
||||
var canvas = el('canvas');
|
||||
canvas.style.cssText = 'max-width:100%;max-height:65vh;touch-action:none;cursor:crosshair;display:none;';
|
||||
body.appendChild(canvas);
|
||||
var foot = el('div', 'px-4 py-3 flex flex-wrap items-center justify-between gap-2');
|
||||
var note = el('p', 'text-xs');
|
||||
note.style.color = 'var(--text-muted,#666)';
|
||||
note.textContent = L.hint + (isPdf(file) ? ' ' + L.pdfNote : '');
|
||||
var btns = el('div', 'flex gap-2');
|
||||
var bCancel = el('button', 'btn btn-secondary text-xs', L.cancel);
|
||||
var bOrig = el('button', 'btn btn-secondary text-xs', L.original);
|
||||
var bOk = el('button', 'btn btn-primary text-xs', L.confirm);
|
||||
[bCancel, bOrig, bOk].forEach(function (b) { b.type = 'button'; btns.appendChild(b); });
|
||||
bOk.disabled = true;
|
||||
foot.appendChild(note);
|
||||
foot.appendChild(btns);
|
||||
box.appendChild(head); box.appendChild(body); box.appendChild(foot);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
function onKey(e) { if (e.key === 'Escape') finish(null); }
|
||||
function finish(value) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
document.removeEventListener('keydown', onKey);
|
||||
overlay.remove();
|
||||
resolve(value);
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
bCancel.onclick = function () { finish(null); };
|
||||
bOrig.onclick = function () { finish(file); };
|
||||
bOk.onclick = function () {
|
||||
if (!src || !rect) return;
|
||||
var out = document.createElement('canvas');
|
||||
out.width = Math.round(rect.w);
|
||||
out.height = Math.round(rect.h);
|
||||
out.getContext('2d').drawImage(src, Math.round(rect.x), Math.round(rect.y),
|
||||
out.width, out.height, 0, 0, out.width, out.height);
|
||||
out.toBlob(function (blob) { finish(blob || file); }, 'image/png');
|
||||
};
|
||||
|
||||
// ---- disegno ----
|
||||
function sw() { return src.width || src.naturalWidth; }
|
||||
function sh() { return src.height || src.naturalHeight; }
|
||||
function handlePoints(r) {
|
||||
var mx = r.x + r.w / 2, my = r.y + r.h / 2, ex = r.x + r.w, ey = r.y + r.h;
|
||||
return [
|
||||
{ n: 'nw', x: r.x, y: r.y }, { n: 'n', x: mx, y: r.y }, { n: 'ne', x: ex, y: r.y },
|
||||
{ n: 'e', x: ex, y: my }, { n: 'se', x: ex, y: ey }, { n: 's', x: mx, y: ey },
|
||||
{ n: 'sw', x: r.x, y: ey }, { n: 'w', x: r.x, y: my }
|
||||
];
|
||||
}
|
||||
function draw() {
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(src, 0, 0, canvas.width, canvas.height);
|
||||
var r = rect; // il canvas di visualizzazione ha la risoluzione della sorgente
|
||||
// velo scuro fuori dal ritaglio (4 fasce)
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.fillRect(0, 0, canvas.width, r.y);
|
||||
ctx.fillRect(0, r.y + r.h, canvas.width, canvas.height - r.y - r.h);
|
||||
ctx.fillRect(0, r.y, r.x, r.h);
|
||||
ctx.fillRect(r.x + r.w, r.y, canvas.width - r.x - r.w, r.h);
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.lineWidth = Math.max(2, canvas.width / 400);
|
||||
ctx.strokeRect(r.x, r.y, r.w, r.h);
|
||||
ctx.fillStyle = '#3b82f6';
|
||||
var hs = Math.max(8, canvas.width / 80);
|
||||
handlePoints(r).forEach(function (p) { ctx.fillRect(p.x - hs / 2, p.y - hs / 2, hs, hs); });
|
||||
}
|
||||
function setSource(s) {
|
||||
src = s;
|
||||
canvas.width = sw();
|
||||
canvas.height = sh();
|
||||
rect = { x: 0, y: 0, w: sw(), h: sh() }; // all'inizio: tutta l'immagine
|
||||
status.style.display = 'none';
|
||||
canvas.style.display = 'block';
|
||||
bOk.disabled = false;
|
||||
draw();
|
||||
}
|
||||
|
||||
// ---- interazione ----
|
||||
var drag = null;
|
||||
function toImg(e) {
|
||||
var b = canvas.getBoundingClientRect();
|
||||
return { x: (e.clientX - b.left) * sw() / b.width, y: (e.clientY - b.top) * sh() / b.height,
|
||||
k: sw() / b.width }; // k = px immagine per px schermo
|
||||
}
|
||||
function hit(p) {
|
||||
var best = null, bd = HANDLE * p.k;
|
||||
handlePoints(rect).forEach(function (h) {
|
||||
var d = Math.max(Math.abs(h.x - p.x), Math.abs(h.y - p.y));
|
||||
if (d <= bd) { bd = d; best = h.n; }
|
||||
});
|
||||
if (best) return best;
|
||||
if (p.x > rect.x && p.x < rect.x + rect.w && p.y > rect.y && p.y < rect.y + rect.h) return 'move';
|
||||
return 'new';
|
||||
}
|
||||
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
||||
canvas.addEventListener('pointerdown', function (e) {
|
||||
if (!src) return;
|
||||
var p = toImg(e);
|
||||
var mode = hit(p);
|
||||
drag = { mode: mode, sx: p.x, sy: p.y, r0: { x: rect.x, y: rect.y, w: rect.w, h: rect.h } };
|
||||
if (mode === 'new') { // nuovo rettangolo: parte dal punto di tocco
|
||||
drag.r0 = { x: clamp(p.x, 0, sw()), y: clamp(p.y, 0, sh()), w: 0, h: 0 };
|
||||
}
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
canvas.addEventListener('pointermove', function (e) {
|
||||
if (!src) return;
|
||||
var p = toImg(e);
|
||||
if (!drag) {
|
||||
var m = hit(p);
|
||||
canvas.style.cursor = m === 'move' ? 'move' : m === 'new' ? 'crosshair'
|
||||
: (m.length === 2 ? (m === 'nw' || m === 'se' ? 'nwse-resize' : 'nesw-resize')
|
||||
: (m === 'n' || m === 's' ? 'ns-resize' : 'ew-resize'));
|
||||
return;
|
||||
}
|
||||
var dx = p.x - drag.sx, dy = p.y - drag.sy, r0 = drag.r0, W = sw(), H = sh();
|
||||
if (drag.mode === 'move') {
|
||||
rect.x = clamp(r0.x + dx, 0, W - r0.w);
|
||||
rect.y = clamp(r0.y + dy, 0, H - r0.h);
|
||||
} else {
|
||||
var x1 = r0.x, y1 = r0.y, x2 = r0.x + r0.w, y2 = r0.y + r0.h, m2 = drag.mode;
|
||||
if (m2 === 'new') { x2 = clamp(p.x, 0, W); y2 = clamp(p.y, 0, H); }
|
||||
else {
|
||||
if (m2.indexOf('w') >= 0) x1 = clamp(r0.x + dx, 0, x2 - MIN_SIDE);
|
||||
if (m2.indexOf('e') >= 0) x2 = clamp(x2 + dx, x1 + MIN_SIDE, W);
|
||||
if (m2.indexOf('n') >= 0) y1 = clamp(r0.y + dy, 0, y2 - MIN_SIDE);
|
||||
if (m2.indexOf('s') >= 0) y2 = clamp(y2 + dy, y1 + MIN_SIDE, H);
|
||||
}
|
||||
rect = { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
|
||||
}
|
||||
bOk.disabled = rect.w < MIN_SIDE || rect.h < MIN_SIDE; // ritaglio troppo piccolo: non confermabile
|
||||
draw();
|
||||
e.preventDefault();
|
||||
});
|
||||
function up() { drag = null; }
|
||||
canvas.addEventListener('pointerup', up);
|
||||
canvas.addEventListener('pointercancel', up);
|
||||
|
||||
// ---- caricamento sorgente ----
|
||||
function fail(err) {
|
||||
console.error('ImageCrop:', err);
|
||||
status.style.display = '';
|
||||
status.textContent = L.error;
|
||||
canvas.style.display = 'none';
|
||||
bOk.disabled = true;
|
||||
}
|
||||
if (isPdf(file)) {
|
||||
loadPdfJs().then(function (lib) {
|
||||
if (cfg.workerSrc) lib.GlobalWorkerOptions.workerSrc = cfg.workerSrc;
|
||||
return file.arrayBuffer().then(function (buf) { return lib.getDocument({ data: buf }).promise; });
|
||||
}).then(function (pdf) {
|
||||
pdfDoc = pdf;
|
||||
if (pdf.numPages > 1) {
|
||||
for (var i = 1; i <= pdf.numPages; i++) {
|
||||
var o = el('option', '', String(i)); o.value = String(i); pageSel.appendChild(o);
|
||||
}
|
||||
pageWrap.style.display = 'flex';
|
||||
}
|
||||
return renderPdfPage(pdf, 1);
|
||||
}).then(setSource).catch(fail);
|
||||
pageSel.onchange = function () {
|
||||
bOk.disabled = true;
|
||||
renderPdfPage(pdfDoc, parseInt(pageSel.value, 10)).then(setSource).catch(fail);
|
||||
};
|
||||
} else {
|
||||
readImage(file).then(setSource).catch(fail);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
global.ImageCrop = { open: open, configure: configure };
|
||||
})(window);
|
||||
@@ -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 || []); }
|
||||
};
|
||||
}
|
||||
@@ -503,6 +503,32 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
||||
<script>
|
||||
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
||||
ImageCrop.configure({
|
||||
pdfSrc: '{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}',
|
||||
workerSrc: '{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}',
|
||||
labels: {
|
||||
title: {{ _('Ritaglia immagine')|tojson }},
|
||||
page: {{ _('Pagina')|tojson }},
|
||||
hint: {{ _('Trascina il rettangolo o i suoi bordi per scegliere la zona da tenere.')|tojson }},
|
||||
pdfNote: {{ _('Un PDF ritagliato viene salvato come immagine PNG.')|tojson }},
|
||||
confirm: {{ _('Conferma ritaglio')|tojson }},
|
||||
original: {{ _('Usa originale')|tojson }},
|
||||
cancel: {{ _('Annulla')|tojson }},
|
||||
loading: {{ _('Caricamento...')|tojson }},
|
||||
error: {{ _('Impossibile aprire il file per il ritaglio.')|tojson }}
|
||||
}
|
||||
});
|
||||
// #56: apre la modale di ritaglio; null = annullato, altrimenti il file da caricare
|
||||
async function tmfCropBeforeUpload(file) {
|
||||
const res = await ImageCrop.open(file);
|
||||
if (res === null || res === file) return res;
|
||||
const base = (file.name || 'file').replace(/\.[^.]+$/, '');
|
||||
return new File([res], base + '-ritaglio.png', { type: 'image/png' });
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
function recipeEditor() {
|
||||
return {
|
||||
@@ -672,6 +698,10 @@ function recipeEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
// #56: scelta di ritaglio (immagine/PDF) prima dell'upload; senza ritaglio il file resta invariato
|
||||
file = await tmfCropBeforeUpload(file);
|
||||
if (!file) return;
|
||||
|
||||
this.uploadingFile = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
|
||||
@@ -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,9 +1158,36 @@
|
||||
{% 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>
|
||||
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
||||
<script>
|
||||
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
||||
ImageCrop.configure({
|
||||
pdfSrc: '{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}',
|
||||
workerSrc: '{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}',
|
||||
labels: {
|
||||
title: {{ _('Ritaglia immagine')|tojson }},
|
||||
page: {{ _('Pagina')|tojson }},
|
||||
hint: {{ _('Trascina il rettangolo o i suoi bordi per scegliere la zona da tenere.')|tojson }},
|
||||
pdfNote: {{ _('Un PDF ritagliato viene salvato come immagine PNG.')|tojson }},
|
||||
confirm: {{ _('Conferma ritaglio')|tojson }},
|
||||
original: {{ _('Usa originale')|tojson }},
|
||||
cancel: {{ _('Annulla')|tojson }},
|
||||
loading: {{ _('Caricamento...')|tojson }},
|
||||
error: {{ _('Impossibile aprire il file per il ritaglio.')|tojson }}
|
||||
}
|
||||
});
|
||||
// #56: apre la modale di ritaglio; null = annullato, altrimenti il file da caricare
|
||||
async function tmfCropBeforeUpload(file) {
|
||||
const res = await ImageCrop.open(file);
|
||||
if (res === null || res === file) return res;
|
||||
const base = (file.name || 'file').replace(/\.[^.]+$/, '');
|
||||
return new File([res], base + '-ritaglio.png', { type: 'image/png' });
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// Force reload when page is restored from browser bfcache (back/forward navigation).
|
||||
// This ensures fresh data is displayed after editing annotations in task_drawing.
|
||||
@@ -1129,8 +1221,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 +1248,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 +1709,8 @@ function taskEditor() {
|
||||
uwl: null,
|
||||
lwl: null,
|
||||
ltl: null,
|
||||
unit: 'mm'
|
||||
unit: 'mm',
|
||||
vision_output: ''
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1634,7 +1734,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 +1788,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 +1813,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 {
|
||||
@@ -1810,6 +1913,10 @@ function taskEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
// #56: scelta di ritaglio (immagine/PDF) prima dell'upload; senza ritaglio il file resta invariato
|
||||
file = await tmfCropBeforeUpload(file);
|
||||
if (!file) return;
|
||||
|
||||
this.saving = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
|
||||
@@ -161,3 +161,109 @@ 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
|
||||
|
||||
|
||||
class TestImageCropInclusion:
|
||||
"""#56: i due editor includono image-crop.js e agganciano il ritaglio all'upload."""
|
||||
|
||||
def test_task_editor_includes_crop(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": "measure", "subtasks": [],
|
||||
"file_path": None, "order_index": 1,
|
||||
}],
|
||||
})
|
||||
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
|
||||
assert "js/image-crop.js" in html
|
||||
assert "tmfCropBeforeUpload(file)" in html
|
||||
|
||||
def test_recipe_editor_includes_crop(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Recipe A"},
|
||||
[{"version_number": 1, "is_current": True}],
|
||||
]
|
||||
html = logged_in_client.get("/maker/recipes/1/edit").get_data(as_text=True)
|
||||
assert "js/image-crop.js" in html
|
||||
assert "tmfCropBeforeUpload(file)" in html
|
||||
|
||||
Reference in New Issue
Block a user