Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c764cf73c6 |
@@ -59,6 +59,45 @@ def _post_login_landing():
|
|||||||
return url_for("auth.profile")
|
return url_for("auth.profile")
|
||||||
|
|
||||||
|
|
||||||
|
def _complete_login(response, fallback_name):
|
||||||
|
"""Dopo una risposta di login riuscita: sessione, preferenze, impostazioni (#59).
|
||||||
|
|
||||||
|
Comune al login con password e al login con PIN, cosi' i due restano identici.
|
||||||
|
Ritorna l'utente salvato in sessione.
|
||||||
|
"""
|
||||||
|
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
|
||||||
|
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
|
||||||
|
# limite configurato non ha mai effetto (#33).
|
||||||
|
session.permanent = True
|
||||||
|
session["api_key"] = response["api_key"]
|
||||||
|
session["user"] = response["user"]
|
||||||
|
session["user_id"] = response["user"]["id"]
|
||||||
|
|
||||||
|
# Applica preferenze utente
|
||||||
|
user = response["user"]
|
||||||
|
if user.get("language_pref"):
|
||||||
|
session["language"] = user["language_pref"]
|
||||||
|
if user.get("theme_pref"):
|
||||||
|
session["theme"] = user["theme_pref"]
|
||||||
|
|
||||||
|
# Carica impostazioni di sistema
|
||||||
|
settings_resp = api_client.get("/api/settings")
|
||||||
|
if not settings_resp.get("error"):
|
||||||
|
logo_path = settings_resp.get("company_logo_path")
|
||||||
|
if logo_path:
|
||||||
|
session["company_logo"] = logo_path
|
||||||
|
auto_logout = settings_resp.get("auto_logout_minutes")
|
||||||
|
if auto_logout:
|
||||||
|
try:
|
||||||
|
session["auto_logout_minutes"] = int(auto_logout)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", fallback_name)), "success")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
"""Login page and form handler."""
|
"""Login page and form handler."""
|
||||||
@@ -81,35 +120,7 @@ def login():
|
|||||||
flash(response.get("detail", _("Credenziali non valide")), "error")
|
flash(response.get("detail", _("Credenziali non valide")), "error")
|
||||||
return render_template("auth/login.html")
|
return render_template("auth/login.html")
|
||||||
|
|
||||||
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
|
user = _complete_login(response, username)
|
||||||
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
|
|
||||||
# limite configurato non ha mai effetto (#33).
|
|
||||||
session.permanent = True
|
|
||||||
session["api_key"] = response["api_key"]
|
|
||||||
session["user"] = response["user"]
|
|
||||||
session["user_id"] = response["user"]["id"]
|
|
||||||
|
|
||||||
# Applica preferenze utente
|
|
||||||
user = response["user"]
|
|
||||||
if user.get("language_pref"):
|
|
||||||
session["language"] = user["language_pref"]
|
|
||||||
if user.get("theme_pref"):
|
|
||||||
session["theme"] = user["theme_pref"]
|
|
||||||
|
|
||||||
# Carica impostazioni di sistema
|
|
||||||
settings_resp = api_client.get("/api/settings")
|
|
||||||
if not settings_resp.get("error"):
|
|
||||||
logo_path = settings_resp.get("company_logo_path")
|
|
||||||
if logo_path:
|
|
||||||
session["company_logo"] = logo_path
|
|
||||||
auto_logout = settings_resp.get("auto_logout_minutes")
|
|
||||||
if auto_logout:
|
|
||||||
try:
|
|
||||||
session["auto_logout_minutes"] = int(auto_logout)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
|
||||||
|
|
||||||
# Redirect dopo login (in base al ruolo)
|
# Redirect dopo login (in base al ruolo)
|
||||||
return redirect(_post_login_landing())
|
return redirect(_post_login_landing())
|
||||||
@@ -121,6 +132,55 @@ def login():
|
|||||||
return render_template("auth/login.html")
|
return render_template("auth/login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/pin-users", methods=["GET"])
|
||||||
|
def pin_users():
|
||||||
|
"""Elenco nomi per la vista Operatore del login (#59): solo id e nome.
|
||||||
|
|
||||||
|
Pubblica come il login; se il backend non risponde o non ha il PIN, lista
|
||||||
|
vuota e la pagina apre il form password.
|
||||||
|
"""
|
||||||
|
if session.get("api_key"):
|
||||||
|
return jsonify([])
|
||||||
|
try:
|
||||||
|
resp = api_client.get("/api/auth/pin-users")
|
||||||
|
except Exception:
|
||||||
|
return jsonify([])
|
||||||
|
if not isinstance(resp, list):
|
||||||
|
return jsonify([])
|
||||||
|
return jsonify([
|
||||||
|
{"id": u.get("id"), "display_name": u.get("display_name", "")}
|
||||||
|
for u in resp if isinstance(u, dict)
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/login-pin", methods=["POST"])
|
||||||
|
def login_pin():
|
||||||
|
"""Login con nome + PIN (#59). JSON in/out; CSRF come il login password
|
||||||
|
(header X-CSRFToken, gestito da CSRFProtect)."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
user_id = data.get("user_id")
|
||||||
|
pin = str(data.get("pin", ""))
|
||||||
|
if not isinstance(user_id, int) or not (len(pin) == 4 and pin.isdigit()):
|
||||||
|
return jsonify({"error": True, "status_code": 422, "detail": _("Il PIN deve essere di 4 cifre")}), 422
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = api_client.post("/api/auth/login-pin", data={"user_id": user_id, "pin": pin})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": True, "status_code": 502,
|
||||||
|
"detail": _("Errore di connessione al server: %(error)s", error=str(e))}), 502
|
||||||
|
|
||||||
|
if response.get("error"):
|
||||||
|
status = response.get("status_code") or 401
|
||||||
|
if status == 401:
|
||||||
|
detail = _("PIN errato")
|
||||||
|
else:
|
||||||
|
detail = response.get("detail") or _("Credenziali non valide")
|
||||||
|
return jsonify({"error": True, "status_code": status, "detail": detail}), status
|
||||||
|
|
||||||
|
_complete_login(response, "") # include il flash di benvenuto
|
||||||
|
return jsonify({"ok": True, "redirect": _post_login_landing()})
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/logout", methods=["POST"])
|
@auth_bp.route("/logout", methods=["POST"])
|
||||||
def logout():
|
def logout():
|
||||||
"""Clear session and redirect to login."""
|
"""Clear session and redirect to login."""
|
||||||
|
|||||||
@@ -377,47 +377,6 @@ def api_upload_file():
|
|||||||
return jsonify(resp), 201
|
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"])
|
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/* Login Operatore: nome + PIN a 4 cifre (#59).
|
||||||
|
* Componente Alpine globale `pinLogin()`, usato da templates/auth/login.html.
|
||||||
|
* Contratto: GET /auth/pin-users -> [{id, display_name}];
|
||||||
|
* POST /auth/login-pin {user_id, pin} -> {ok, redirect} | {error, detail}. */
|
||||||
|
function pinLogin() {
|
||||||
|
return {
|
||||||
|
mode: 'pin', // vista Operatore di default; se l'elenco e' vuoto/fallisce passa a 'password'
|
||||||
|
users: [],
|
||||||
|
selected: null,
|
||||||
|
pin: '',
|
||||||
|
error: '',
|
||||||
|
busy: false,
|
||||||
|
|
||||||
|
init: function () {
|
||||||
|
var self = this;
|
||||||
|
fetch(window.TMF_PIN.usersUrl, { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; })
|
||||||
|
.then(function (list) {
|
||||||
|
self.users = Array.isArray(list) ? list : [];
|
||||||
|
if (!self.users.length) self.mode = 'password'; // elenco vuoto: form password
|
||||||
|
})
|
||||||
|
.catch(function () { self.users = []; self.mode = 'password'; });
|
||||||
|
},
|
||||||
|
select: function (u) { this.selected = u; this.pin = ''; this.error = ''; },
|
||||||
|
back: function () { this.selected = null; this.pin = ''; this.error = ''; },
|
||||||
|
del: function () { this.pin = this.pin.slice(0, -1); },
|
||||||
|
press: function (d) {
|
||||||
|
if (this.busy || this.pin.length >= 4) return;
|
||||||
|
this.pin += d;
|
||||||
|
this.error = '';
|
||||||
|
if (this.pin.length === 4) this.submit(); // invio automatico alla quarta cifra
|
||||||
|
},
|
||||||
|
submit: function () {
|
||||||
|
var self = this;
|
||||||
|
var csrf = document.querySelector('meta[name=csrf-token]');
|
||||||
|
self.busy = true;
|
||||||
|
fetch(window.TMF_PIN.loginUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRFToken': (csrf && csrf.content) || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ user_id: self.selected.id, pin: self.pin })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().catch(function () { return {}; }); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d.ok && d.redirect) { window.location.href = d.redirect; return; }
|
||||||
|
self.error = d.detail || window.TMF_PIN.msgNet; // 401 PIN errato, 423 blocco con ora
|
||||||
|
self.pin = '';
|
||||||
|
self.busy = false;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
self.error = window.TMF_PIN.msgNet;
|
||||||
|
self.pin = '';
|
||||||
|
self.busy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
// 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 || []); }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -26,8 +26,50 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<div x-data="pinLogin()" x-init="init()">
|
||||||
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
<!-- Vista Operatore: nome + PIN (#59) -->
|
||||||
|
<div x-show="mode === 'pin'" x-cloak>
|
||||||
|
<!-- Elenco nomi -->
|
||||||
|
<div x-show="!selected">
|
||||||
|
<p class="text-sm text-center text-[var(--text-secondary)] mb-4">{{ _('Chi sei?') }}</p>
|
||||||
|
<div class="grid grid-cols-2 gap-3" id="pin-user-list">
|
||||||
|
<template x-for="u in users" :key="u.id">
|
||||||
|
<button type="button" @click="select(u)"
|
||||||
|
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tastierino -->
|
||||||
|
<div x-show="selected" id="pin-pad">
|
||||||
|
<p class="text-center font-semibold mb-1" x-text="selected && selected.display_name"></p>
|
||||||
|
<p class="text-sm text-center text-[var(--text-secondary)] mb-3">{{ _('Inserisci il PIN') }}</p>
|
||||||
|
<div class="flex justify-center gap-3 mb-3" aria-hidden="true">
|
||||||
|
<template x-for="i in 4" :key="i">
|
||||||
|
<span class="h-4 w-4 rounded-full border-2 border-[var(--border-color)]"
|
||||||
|
:class="pin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-center text-red-600 min-h-[1.25rem] mb-2" role="alert" x-text="error"></p>
|
||||||
|
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
|
||||||
|
<template x-for="d in ['1','2','3','4','5','6','7','8','9']" :key="d">
|
||||||
|
<button type="button" @click="press(d)" :disabled="busy"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
|
||||||
|
</template>
|
||||||
|
<button type="button" @click="back()" :disabled="busy"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
|
||||||
|
<button type="button" @click="press('0')" :disabled="busy"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
|
||||||
|
<button type="button" @click="del()" :disabled="busy"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-6 text-center">
|
||||||
|
<a href="#" @click.prevent="mode = 'password'" class="text-sm text-primary-600 hover:underline">{{ _('Accedi con password') }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Form (password: admin e chi non ha PIN) -->
|
||||||
|
<form x-show="mode === 'password'" method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<!-- Username -->
|
<!-- Username -->
|
||||||
<div>
|
<div>
|
||||||
@@ -98,6 +140,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-4 text-center" x-show="mode === 'password' && users.length" x-cloak>
|
||||||
|
<a href="#" @click.prevent="mode = 'pin'" class="text-sm text-primary-600 hover:underline">{{ _('Accedi con PIN') }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Help Text -->
|
<!-- Help Text -->
|
||||||
<div class="mt-6 text-center">
|
<div class="mt-6 text-center">
|
||||||
<p class="text-xs text-[var(--text-muted)]">
|
<p class="text-xs text-[var(--text-muted)]">
|
||||||
@@ -115,3 +162,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
/* Configurazione e testi per pin-pad.js (#59) */
|
||||||
|
window.TMF_PIN = {
|
||||||
|
usersUrl: {{ url_for('auth.pin_users')|tojson }},
|
||||||
|
loginUrl: {{ url_for('auth.login_pin')|tojson }},
|
||||||
|
msgNet: {{ _('Errore di connessione al server')|tojson }}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/pin-pad.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -277,8 +277,6 @@
|
|||||||
<option value="note">{{ _('Nota') }}</option>
|
<option value="note">{{ _('Nota') }}</option>
|
||||||
<option value="measure">{{ _('Misura') }}</option>
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
<option value="drawing">{{ _('Disegno') }}</option>
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
|
||||||
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
|
||||||
</select>
|
</select>
|
||||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
||||||
@@ -462,55 +460,6 @@
|
|||||||
|
|
||||||
<div class="tmf-card-body space-y-4">
|
<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 ========== -->
|
<!-- ========== Task File Upload / Preview ========== -->
|
||||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
<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">
|
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||||
@@ -594,8 +543,6 @@
|
|||||||
<option value="note">{{ _('Nota') }}</option>
|
<option value="note">{{ _('Nota') }}</option>
|
||||||
<option value="measure">{{ _('Misura') }}</option>
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
<option value="drawing">{{ _('Disegno') }}</option>
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
|
||||||
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -752,18 +699,6 @@
|
|||||||
class="tmf-input"
|
class="tmf-input"
|
||||||
placeholder="{{ _('Es. Diametro foro principale') }}">
|
placeholder="{{ _('Es. Diametro foro principale') }}">
|
||||||
</div>
|
</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 -->
|
<!-- Measurement Type -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
||||||
@@ -1158,7 +1093,6 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
{# 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/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 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>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/annotation-viewer.js') }}?v=6"></script>
|
||||||
@@ -1195,11 +1129,8 @@ function taskEditor() {
|
|||||||
addingSubtaskForTask: null,
|
addingSubtaskForTask: null,
|
||||||
newSubtask: {
|
newSubtask: {
|
||||||
marker_number: 1, description: '', measurement_type: '',
|
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 ----
|
// ---- Delete modals ----
|
||||||
deleteTaskModal: false,
|
deleteTaskModal: false,
|
||||||
@@ -1222,10 +1153,6 @@ function taskEditor() {
|
|||||||
// Init
|
// Init
|
||||||
// ============================================================
|
// ============================================================
|
||||||
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
|
// Sort tasks by order_index on load
|
||||||
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
||||||
|
|
||||||
@@ -1683,8 +1610,7 @@ function taskEditor() {
|
|||||||
uwl: null,
|
uwl: null,
|
||||||
lwl: null,
|
lwl: null,
|
||||||
ltl: null,
|
ltl: null,
|
||||||
unit: 'mm',
|
unit: 'mm'
|
||||||
vision_output: ''
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1708,8 +1634,7 @@ function taskEditor() {
|
|||||||
uwl: this.newSubtask.uwl,
|
uwl: this.newSubtask.uwl,
|
||||||
lwl: this.newSubtask.lwl,
|
lwl: this.newSubtask.lwl,
|
||||||
ltl: this.newSubtask.ltl,
|
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 {
|
try {
|
||||||
@@ -1762,8 +1687,7 @@ function taskEditor() {
|
|||||||
uwl: subtask.uwl,
|
uwl: subtask.uwl,
|
||||||
lwl: subtask.lwl,
|
lwl: subtask.lwl,
|
||||||
ltl: subtask.ltl,
|
ltl: subtask.ltl,
|
||||||
unit: subtask.unit || 'mm',
|
unit: subtask.unit || 'mm'
|
||||||
vision_output: subtask.vision_output || ''
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1787,8 +1711,7 @@ function taskEditor() {
|
|||||||
uwl: this.newSubtask.uwl,
|
uwl: this.newSubtask.uwl,
|
||||||
lwl: this.newSubtask.lwl,
|
lwl: this.newSubtask.lwl,
|
||||||
ltl: this.newSubtask.ltl,
|
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 {
|
try {
|
||||||
|
|||||||
@@ -136,3 +136,67 @@ class TestLanguageSwitch:
|
|||||||
with client.session_transaction() as sess:
|
with client.session_transaction() as sess:
|
||||||
# Invalid language should NOT be stored
|
# Invalid language should NOT be stored
|
||||||
assert sess.get("language") != "xx"
|
assert sess.get("language") != "xx"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPinLogin:
|
||||||
|
"""Login Operatore con nome + PIN (#59)."""
|
||||||
|
|
||||||
|
USER = {
|
||||||
|
"id": 7, "username": "mario", "display_name": "Mario Rossi",
|
||||||
|
"roles": ["MeasurementTec"], "is_admin": False,
|
||||||
|
"language_pref": "it", "theme_pref": "light", "active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_login_page_has_pin_view_and_password_link(self, client):
|
||||||
|
resp = client.get("/auth/login")
|
||||||
|
assert b"pin-pad.js" in resp.data
|
||||||
|
assert b"pin-pad" in resp.data # tastierino presente nel markup
|
||||||
|
assert b'name="password"' in resp.data # form password ancora presente
|
||||||
|
|
||||||
|
def test_pin_users_listed(self, client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = [
|
||||||
|
{"id": 7, "display_name": "Mario Rossi", "secret": "x"},
|
||||||
|
]
|
||||||
|
resp = client.get("/auth/pin-users")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == [{"id": 7, "display_name": "Mario Rossi"}]
|
||||||
|
|
||||||
|
def test_pin_users_empty_on_error(self, client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = {"error": True, "detail": "boom"}
|
||||||
|
assert client.get("/auth/pin-users").get_json() == []
|
||||||
|
|
||||||
|
def test_pin_users_empty_list_falls_back(self, client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = []
|
||||||
|
assert client.get("/auth/pin-users").get_json() == []
|
||||||
|
|
||||||
|
def test_login_pin_success_sets_session(self, client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"api_key": "pin-key", "user": self.USER}
|
||||||
|
mock_api_client.get.return_value = {}
|
||||||
|
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "1234"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["ok"] is True
|
||||||
|
assert resp.get_json()["redirect"]
|
||||||
|
mock_api_client.post.assert_called_once_with(
|
||||||
|
"/api/auth/login-pin", data={"user_id": 7, "pin": "1234"})
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
assert sess["api_key"] == "pin-key"
|
||||||
|
assert sess["user_id"] == 7
|
||||||
|
assert sess.permanent is True
|
||||||
|
|
||||||
|
def test_login_pin_wrong_pin_401(self, client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"error": True, "status_code": 401, "detail": "x"}
|
||||||
|
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json()["detail"] == "PIN errato"
|
||||||
|
|
||||||
|
def test_login_pin_locked_423_shows_detail(self, client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"error": True, "status_code": 423, "detail": "Utente bloccato fino alle 10:15"}
|
||||||
|
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
|
||||||
|
assert resp.status_code == 423
|
||||||
|
assert "10:15" in resp.get_json()["detail"]
|
||||||
|
|
||||||
|
def test_login_pin_invalid_pin_422(self, client, mock_api_client):
|
||||||
|
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "12"})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|||||||
@@ -161,82 +161,3 @@ class TestVersionConflictProxy:
|
|||||||
|
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
assert resp.get_json()["detail"] == self._CONFLICT["detail"]
|
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user