Compare commits

..

1 Commits

Author SHA1 Message Date
Adriano c764cf73c6 Login Operatore con nome + PIN: elenco nomi, tastierino, route login-pin (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:23:51 +02:00
14 changed files with 281 additions and 654 deletions
-13
View File
@@ -9,7 +9,6 @@ from src.backend.api.middleware.api_key import get_current_user, require_maker,
from src.backend.models.orm.user import User
from src.backend.models.api.recipe import (
RecipeCreate,
RecipeDuplicate,
RecipeListResponse,
RecipeResponse,
RecipeUpdate,
@@ -53,18 +52,6 @@ async def create_recipe(
return _recipe_to_response(recipe)
@router.post("/{recipe_id}/duplicate", response_model=RecipeResponse, status_code=201)
async def duplicate_recipe(
recipe_id: int,
data: RecipeDuplicate,
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Duplicate a recipe with its current tasks under a new code (#52). Requires Maker role."""
recipe = await recipe_service.duplicate_recipe(db, recipe_id, data.code, data.name, user)
return _recipe_to_response(recipe)
@router.get("", response_model=RecipeListResponse)
async def list_recipes(
page: int = Query(1, ge=1),
-6
View File
@@ -26,12 +26,6 @@ class RecipeCreate(BaseModel):
annotations_json: Optional[dict] = None
class RecipeDuplicate(BaseModel):
"""Schema for duplicating a recipe (#52): only the new code and name."""
code: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
class RecipeUpdate(BaseModel):
"""Schema for updating a recipe (creates new version)."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
-72
View File
@@ -253,78 +253,6 @@ async def create_recipe(
return result.scalar_one()
async def duplicate_recipe(
db: AsyncSession,
recipe_id: int,
new_code: str,
new_name: str,
user: User,
) -> Recipe:
"""Duplicate a recipe (#52): new code/name, v1 with the current version's tasks.
The source recipe is left untouched.
"""
source = await _get_recipe_or_404(db, recipe_id)
existing = await db.execute(select(Recipe).where(Recipe.code == new_code))
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Recipe code '{new_code}' already exists",
)
# Same header fields create_recipe persists, taken from the source recipe
recipe = Recipe(
code=new_code,
name=new_name,
description=source.description,
image_path=source.image_path,
measurement_interval_minutes=source.measurement_interval_minutes,
requires_lot=source.requires_lot,
requires_serial=source.requires_serial,
allow_manual_input=source.allow_manual_input,
created_by=user.id,
)
db.add(recipe)
await db.flush()
version = RecipeVersion(
recipe_id=recipe.id,
version_number=1,
is_current=True,
created_by=user.id,
change_notes=f"Duplicated from {source.code}",
)
db.add(version)
await db.flush()
# Reuse the existing deep copy of tasks/subtasks from the current version
source_version = await _get_current_version(db, source.id)
if source_version is not None:
await _copy_tasks_to_version(db, source_version, version)
await _write_audit(
db,
recipe_id=recipe.id,
old_version_id=None,
new_version_id=version.id,
changed_by=user.id,
change_type="CREATE",
change_reason=f"Duplicated from recipe {source.code}",
)
result = await db.execute(
select(Recipe)
.where(Recipe.id == recipe.id)
.options(
selectinload(Recipe.versions)
.selectinload(RecipeVersion.tasks)
.selectinload(RecipeTask.subtasks)
)
)
return result.scalar_one()
async def create_new_version(
db: AsyncSession,
recipe_id: int,
@@ -1,74 +0,0 @@
"""Tests for POST /api/recipes/{id}/duplicate (#52)."""
from httpx import AsyncClient
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers, create_test_recipe
class TestDuplicateRecipe:
async def test_duplicate_copies_tasks_and_keeps_original(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession
):
src = await create_test_recipe(db_session, maker_user.id)
src_id = src.id
before_tasks = (await db_session.execute(select(func.count(RecipeTask.id)))).scalar_one()
before_subs = (await db_session.execute(select(func.count(RecipeSubtask.id)))).scalar_one()
resp = await client.post(
f"/api/recipes/{src_id}/duplicate",
json={"code": "REC-COPIA", "name": "Copia"},
headers=auth_headers(maker_user),
)
assert resp.status_code == 201
data = resp.json()
assert data["code"] == "REC-COPIA" and data["name"] == "Copia"
assert data["id"] != src_id
assert data["description"] == "A recipe for testing"
assert data["current_version"]["version_number"] == 1
# Tasks and subtasks doubled: copied, not moved
after_tasks = (await db_session.execute(select(func.count(RecipeTask.id)))).scalar_one()
after_subs = (await db_session.execute(select(func.count(RecipeSubtask.id)))).scalar_one()
assert after_tasks == before_tasks * 2
assert after_subs == before_subs * 2
new_version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == data["id"])
)).scalar_one()
new_tasks = (await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == new_version.id)
)).scalars().all()
assert len(new_tasks) == before_tasks
# Original unchanged
orig = (await db_session.execute(select(Recipe).where(Recipe.id == src_id))).scalar_one()
assert orig.code == "REC-001" and orig.name == "Test Recipe"
orig_versions = (await db_session.execute(
select(func.count(RecipeVersion.id)).where(RecipeVersion.recipe_id == src_id)
)).scalar_one()
assert orig_versions == 1
async def test_duplicate_code_conflict(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession
):
src = await create_test_recipe(db_session, maker_user.id)
resp = await client.post(
f"/api/recipes/{src.id}/duplicate",
json={"code": "REC-001", "name": "X"},
headers=auth_headers(maker_user),
)
assert resp.status_code == 409
async def test_duplicate_missing_recipe(
self, client: AsyncClient, maker_user: User
):
resp = await client.post(
"/api/recipes/99999/duplicate",
json={"code": "NEW", "name": "X"},
headers=auth_headers(maker_user),
)
assert resp.status_code == 404
+89 -29
View File
@@ -59,6 +59,45 @@ def _post_login_landing():
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"])
def login():
"""Login page and form handler."""
@@ -81,35 +120,7 @@ def login():
flash(response.get("detail", _("Credenziali non valide")), "error")
return render_template("auth/login.html")
# 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", username)), "success")
user = _complete_login(response, username)
# Redirect dopo login (in base al ruolo)
return redirect(_post_login_landing())
@@ -121,6 +132,55 @@ def login():
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"])
def logout():
"""Clear session and redirect to login."""
+1 -70
View File
@@ -57,20 +57,7 @@ def recipe_list():
@role_required("Maker")
def recipe_new():
"""Create new recipe."""
# #49: loop duration pre-filled from the system default (new recipes only);
# missing/empty/invalid key -> None -> behaviour unchanged
default_interval = None
settings = api_client.get("/api/settings")
if isinstance(settings, dict) and not settings.get("error"):
try:
value = int(str(settings.get("default_measurement_interval_minutes", "")).strip())
if 1 <= value <= 1440:
default_interval = value
except ValueError:
pass
return render_template(
"maker/recipe_editor.html", recipe=None, default_interval=default_interval
)
return render_template("maker/recipe_editor.html", recipe=None)
@maker_bp.route("/recipes/<int:recipe_id>/edit")
@@ -214,21 +201,6 @@ def api_create_recipe():
return jsonify(resp), 201
@maker_bp.route("/api/recipes/<int:recipe_id>/duplicate", methods=["POST"])
@login_required
@role_required("Maker")
def api_duplicate_recipe(recipe_id: int):
"""Proxy: Duplicate a recipe under a new code/name (#52)."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/recipes/{recipe_id}/duplicate", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"])
@login_required
@role_required("Maker")
@@ -405,47 +377,6 @@ 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,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>
</div>
<!-- Login Form -->
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
<div x-data="pinLogin()" x-init="init()">
<!-- 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() }}">
<!-- Username -->
<div>
@@ -98,6 +140,11 @@
</div>
</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 -->
<div class="mt-6 text-center">
<p class="text-xs text-[var(--text-muted)]">
@@ -115,3 +162,15 @@
</div>
</div>
{% 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 %}
@@ -526,8 +526,7 @@ function recipeEditor() {
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
// ---- Measurement interval ----
// #49: a new recipe starts from the system default duration (default_interval), an existing one never does
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else (default_interval if not recipe and default_interval else 'null'))|tojson }},
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
// ---- Rules of measurement (points 8 and 9) ----
// A new recipe forbids typing: the caliper is the instrument, and permission
@@ -10,51 +10,6 @@
filter: 'all',
deleteModal: false,
deleteTarget: null,
// #52: duplicate modal state
dupModal: false,
dupSource: null,
dupCode: '',
dupName: '',
dupError: '',
dupBusy: false,
openDupModal(recipe) {
this.dupSource = recipe;
this.dupCode = recipe.code + '-COPIA';
this.dupName = recipe.name + ' ' + {{ _('(copia)')|tojson_attr }};
this.dupError = '';
this.dupModal = true;
},
async duplicateRecipe() {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
this.dupBusy = true;
this.dupError = '';
try {
const resp = await fetch(`/maker/api/recipes/${this.dupSource.id}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
body: JSON.stringify({ code: this.dupCode.trim(), name: this.dupName.trim() })
});
const data = await resp.json().catch(() => ({}));
if (resp.ok) {
// open the new recipe in the editor
window.location.href = '/maker/recipes/' + data.id + '/edit';
return;
}
if (resp.status === 409) {
this.dupError = {{ _('Esiste già una ricetta con questo codice')|tojson_attr }};
} else if (resp.status === 422) {
this.dupError = {{ _('Codice o nome non validi')|tojson_attr }};
} else {
this.dupError = data.detail || data.error || {{ _('Errore durante la duplicazione')|tojson_attr }};
}
} catch (err) {
this.dupError = {{ _('Errore di connessione')|tojson_attr }};
} finally {
this.dupBusy = false;
}
},
get filteredRecipes() {
let filtered = this.recipes;
@@ -310,15 +265,6 @@
{{ _('Versioni') }}
</a>
<!-- Duplica (#52) -->
<button @click="openDupModal(recipe)"
class="btn btn-secondary gap-1.5 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{{ _('Duplica') }}
</button>
<!-- Spacer -->
<div class="flex-1"></div>
@@ -372,42 +318,6 @@
</a>
</div>
<!-- Duplicate Modal (#52) -->
<div x-show="dupModal"
x-cloak
@keydown.escape.window="dupModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true">
<div @click="dupModal = false" class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex min-h-full items-center justify-center p-4">
<div class="tmf-card max-w-lg w-full relative p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">
{{ _('Duplica ricetta') }}
</h3>
<div class="mb-3">
<label class="tmf-label" for="dup-code">{{ _('Codice') }}</label>
<input id="dup-code" type="text" x-model="dupCode" maxlength="100" class="tmf-input">
</div>
<div class="mb-3">
<label class="tmf-label" for="dup-name">{{ _('Nome') }}</label>
<input id="dup-name" type="text" x-model="dupName" maxlength="255" class="tmf-input">
</div>
<p x-show="dupError" x-text="dupError" class="text-sm text-red-600 mb-3"></p>
<div class="flex flex-col sm:flex-row gap-3">
<button @click="dupModal = false" class="btn btn-secondary flex-1 justify-center">
{{ _('Annulla') }}
</button>
<button @click="duplicateRecipe()"
:disabled="dupBusy || !dupCode.trim() || !dupName.trim()"
class="btn btn-primary flex-1 justify-center">
{{ _('Duplica') }}
</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div x-show="deleteModal"
x-cloak
@@ -277,8 +277,6 @@
<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') }}
@@ -462,55 +460,6 @@
<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">
@@ -594,8 +543,6 @@
<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>
@@ -752,18 +699,6 @@
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>
@@ -1158,7 +1093,6 @@
{% 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>
@@ -1195,11 +1129,8 @@ function taskEditor() {
addingSubtaskForTask: null,
newSubtask: {
marker_number: 1, description: '', measurement_type: '',
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm',
vision_output: ''
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm'
},
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
visionOutputs: {},
// ---- Delete modals ----
deleteTaskModal: false,
@@ -1222,10 +1153,6 @@ 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));
@@ -1683,8 +1610,7 @@ function taskEditor() {
uwl: null,
lwl: null,
ltl: null,
unit: 'mm',
vision_output: ''
unit: 'mm'
};
},
@@ -1708,8 +1634,7 @@ function taskEditor() {
uwl: this.newSubtask.uwl,
lwl: this.newSubtask.lwl,
ltl: this.newSubtask.ltl,
unit: this.newSubtask.unit || 'mm',
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
unit: this.newSubtask.unit || 'mm'
};
try {
@@ -1762,8 +1687,7 @@ function taskEditor() {
uwl: subtask.uwl,
lwl: subtask.lwl,
ltl: subtask.ltl,
unit: subtask.unit || 'mm',
vision_output: subtask.vision_output || ''
unit: subtask.unit || 'mm'
};
},
@@ -1787,8 +1711,7 @@ function taskEditor() {
uwl: this.newSubtask.uwl,
lwl: this.newSubtask.lwl,
ltl: this.newSubtask.ltl,
unit: this.newSubtask.unit || 'mm',
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
unit: this.newSubtask.unit || 'mm'
};
try {
+64
View File
@@ -136,3 +136,67 @@ class TestLanguageSwitch:
with client.session_transaction() as sess:
# Invalid language should NOT be stored
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()
-134
View File
@@ -71,61 +71,6 @@ class TestRecipeNew:
assert resp.status_code == 200
def test_recipe_new_default_interval_present(self, logged_in_client, mock_api_client):
"""#49: default_measurement_interval_minutes pre-fills the loop duration."""
mock_api_client.get.return_value = {"default_measurement_interval_minutes": "45"}
html = logged_in_client.get("/maker/recipes/new").get_data(as_text=True)
assert "measurementIntervalMinutes: 45," in html
def test_recipe_new_default_interval_absent(self, logged_in_client, mock_api_client):
"""#49: key missing, empty or invalid -> unchanged behaviour (null)."""
for payload in ({}, {"default_measurement_interval_minutes": ""},
{"default_measurement_interval_minutes": "abc"},
{"default_measurement_interval_minutes": "5000"}):
mock_api_client.get.return_value = payload
html = logged_in_client.get("/maker/recipes/new").get_data(as_text=True)
assert 'measurementIntervalMinutes: "null",' in html
def test_recipe_edit_ignores_default_interval(self, logged_in_client, mock_api_client):
"""#49: an existing recipe never receives the default."""
def fake_get(path, *a, **k):
if path == "/api/recipes/7":
return {"id": 7, "code": "R", "name": "R"}
if path.endswith("/versions"):
return []
return {"default_measurement_interval_minutes": "45"}
mock_api_client.get.side_effect = fake_get
html = logged_in_client.get("/maker/recipes/7/edit").get_data(as_text=True)
assert 'measurementIntervalMinutes: "null",' in html
class TestRecipeDuplicateProxy:
"""POST /maker/api/recipes/<id>/duplicate (#52)."""
def test_duplicate_proxy_ok(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"id": 9, "code": "A-COPIA"}
resp = logged_in_client.post(
"/maker/api/recipes/3/duplicate", json={"code": "A-COPIA", "name": "A (copia)"}
)
assert resp.status_code == 201
assert resp.get_json()["id"] == 9
mock_api_client.post.assert_called_with(
"/api/recipes/3/duplicate", data={"code": "A-COPIA", "name": "A (copia)"}
)
def test_duplicate_proxy_conflict(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 409, "detail": "exists"}
resp = logged_in_client.post(
"/maker/api/recipes/3/duplicate", json={"code": "A", "name": "A"}
)
assert resp.status_code == 409
def test_recipe_list_has_duplicate_button(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
html = logged_in_client.get("/maker/recipes").get_data(as_text=True)
assert "openDupModal" in html
class TestRoleRequired:
"""Maker role enforcement tests."""
@@ -216,82 +161,3 @@ 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