Ricetta DXF da camera: pannello editor, stato calibrazione stazioni, proxy camera (#83)
Rotte maker: elenco stazioni con camera, grab per stazione, stato calibrazione dall'acquisitore (C1), inoltro a B3. Pagina stazioni: stato e link «Calibra». Test con api_client e requests finti; traduzioni IT/EN. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<int:station_id>/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/<int:station_id>/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/<int:task_id>/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/<path:file_path>", methods=["GET"])
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
|
||||
@@ -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/<id>/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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -50,6 +50,7 @@
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden lg:table-cell">{{ _('Ricette collegate') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Calibrazione') }}</th>
|
||||
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
|
||||
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
|
||||
</tr>
|
||||
@@ -81,6 +82,25 @@
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
{# Calibrazione (#83): stato letto dall'acquisitore della camera, link alla sua pagina «Calibra» #}
|
||||
<td class="px-4 py-3 hidden md:table-cell" @click.stop
|
||||
x-data="stationCalibrationCell(station, {{ {'uncalibrated': _('non calibrata'), 'unreachable': _('acquisitore non raggiungibile')}|tojson_attr }})">
|
||||
<template x-if="!hasCamera">
|
||||
<span class="text-xs italic text-[var(--text-secondary)]">{{ _('Nessuna camera') }}</span>
|
||||
</template>
|
||||
<template x-if="hasCamera">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<span :class="state === 'calibrated' ? 'text-emerald-700 dark:text-emerald-300'
|
||||
: (state === 'uncalibrated' || state === 'unreachable') ? 'text-amber-700 dark:text-amber-300'
|
||||
: 'text-[var(--text-secondary)]'">
|
||||
<span x-show="state === 'calibrated'">{{ _('Calibrazione') }}: </span><span x-text="state ? label : '...'"></span>
|
||||
</span>
|
||||
<a :href="calibrationUrl" target="_blank" rel="noopener"
|
||||
x-show="calibrationUrl"
|
||||
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
|
||||
:class="station.active
|
||||
@@ -459,6 +479,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
|
||||
<script>
|
||||
function stationManagement(initialStations, initialRecipes) {
|
||||
return {
|
||||
|
||||
@@ -550,7 +550,8 @@
|
||||
<!-- ========== 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 }})">
|
||||
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})"
|
||||
@dxf-recipe-saved="init()">{# #83: dopo «Salva ricetta» da DXF si rilegge la ricetta #}
|
||||
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||
{{ _('Ricetta visione') }}
|
||||
</p>
|
||||
@@ -593,6 +594,93 @@
|
||||
<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>
|
||||
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
|
||||
<template x-if="task.task_type === 'dxf_compare'">
|
||||
<div class="mt-4 pt-3 border-t border-[var(--border-color)]"
|
||||
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata')}|tojson_attr }})">
|
||||
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
|
||||
{{ _('Crea ricetta da DXF') }}
|
||||
</button>
|
||||
<div x-show="open" x-cloak class="mt-3 space-y-3">
|
||||
<!-- Stazione -->
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Stazione') }}</label>
|
||||
<select class="tmf-input text-xs" x-model="stationId" @change="onStation()">
|
||||
<option value="">{{ _('Scegli la stazione') }}</option>
|
||||
<template x-for="st in stations" :key="st.id">
|
||||
<option :value="String(st.id)" x-text="st.code + ' - ' + st.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p x-show="!stations.length" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Nessuna stazione con camera configurata') }}</p>
|
||||
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
|
||||
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
|
||||
</p>
|
||||
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600">
|
||||
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
|
||||
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
|
||||
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
|
||||
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Immagine: camera o file -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button type="button" class="btn btn-secondary text-xs" @click="grab()"
|
||||
:disabled="!stationId || grabbing">{{ _('Acquisisci dalla camera') }}</button>
|
||||
<label class="btn btn-secondary text-xs cursor-pointer">
|
||||
{{ _('Carica immagine') }}
|
||||
<input type="file" accept="image/*" class="hidden" @change="onImage($event)">
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
|
||||
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pochi parametri -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Banda ± mm') }}</label>
|
||||
<input type="number" step="0.01" min="0.001" class="tmf-input text-xs" x-model.number="bandMm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Soglia punti in banda %%') }}</label>
|
||||
<input type="number" step="1" min="1" max="100" class="tmf-input text-xs" x-model.number="minPointsPct">
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" x-model="dark">
|
||||
{{ _('Pezzo scuro su fondo chiaro (controluce)') }}
|
||||
</label>
|
||||
<!-- Azioni -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-secondary text-xs" @click="run(false)"
|
||||
:disabled="!canRun">{{ _('Prova') }}</button>
|
||||
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
|
||||
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
|
||||
</div>
|
||||
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
||||
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
|
||||
<!-- Anteprima con overlay e quattro valori -->
|
||||
<div x-show="imageSrc" class="relative">
|
||||
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]"
|
||||
@load="drawOverlay()">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||
</div>
|
||||
<table x-show="result" class="tmf-table text-xs">
|
||||
<tbody>
|
||||
<template x-for="v in values" :key="v.key">
|
||||
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1245,6 +1333,8 @@
|
||||
{# 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='js/station_calibration.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.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>
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user