Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3856ee335a |
@@ -1,7 +1,4 @@
|
||||
"""System settings router - read/update settings, upload company logo."""
|
||||
import json
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -21,23 +18,6 @@ _VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
|
||||
_VALID_CSV_DECIMAL_SEPARATORS = {",", "."}
|
||||
|
||||
|
||||
def _validate_string_list(key: str, value: str) -> None:
|
||||
"""#49: array JSON di stringhe non vuote, max 50 voci (vuoto = nessuna lista)."""
|
||||
try:
|
||||
items = json.loads(value)
|
||||
except ValueError:
|
||||
items = None
|
||||
if (
|
||||
not isinstance(items, list)
|
||||
or len(items) > 50
|
||||
or not all(isinstance(i, str) and i.strip() for i in items)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{key} deve essere vuoto o un array JSON di al massimo 50 stringhe non vuote",
|
||||
)
|
||||
|
||||
|
||||
def _validate_setting_value(key: str, value: str) -> None:
|
||||
"""Raise 422 for a value that would break the code reading this key.
|
||||
|
||||
@@ -61,29 +41,6 @@ def _validate_setting_value(key: str, value: str) -> None:
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="auto_logout_minutes deve essere vuoto (disabilitato) o un intero fra 1 e 480",
|
||||
)
|
||||
elif key == "default_measurement_interval_minutes":
|
||||
# #47: vuoto = nessun default
|
||||
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 1440)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="default_measurement_interval_minutes deve essere vuoto o un intero fra 1 e 1440",
|
||||
)
|
||||
elif key in ("general_tolerance_plus", "general_tolerance_minus"):
|
||||
# #48: decimale >= 0 con punto; vuoto = nessun default
|
||||
if value != "":
|
||||
try:
|
||||
number = Decimal(value)
|
||||
ok = number.is_finite() and number >= 0
|
||||
except InvalidOperation:
|
||||
ok = False
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{key} deve essere vuoto o un numero decimale maggiore o uguale a 0 (punto decimale)",
|
||||
)
|
||||
elif key in ("list_measurement_types", "list_units"):
|
||||
if value != "":
|
||||
_validate_string_list(key, value)
|
||||
elif key in ("company_name", "company_logo_path"):
|
||||
if not value.strip() or len(value) > 200:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -176,50 +176,6 @@ class TestUpdateSettingsValidation:
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# --- #47/#48/#49: chiavi Impostazioni di Tràfilo ---
|
||||
|
||||
@pytest.mark.parametrize("key,value", [
|
||||
("default_measurement_interval_minutes", "1"),
|
||||
("default_measurement_interval_minutes", "1440"),
|
||||
("default_measurement_interval_minutes", ""),
|
||||
("general_tolerance_plus", "0"),
|
||||
("general_tolerance_plus", "0.05"),
|
||||
("general_tolerance_minus", ""),
|
||||
("list_measurement_types", '["Lunghezza", "Diametro"]'),
|
||||
("list_units", "[]"),
|
||||
("list_units", ""),
|
||||
])
|
||||
async def test_new_keys_valid_accepted(
|
||||
self, client: AsyncClient, admin_user: User, key: str, value: str
|
||||
):
|
||||
resp = await client.put(
|
||||
"/api/settings/", headers=auth_headers(admin_user), json={key: value}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
@pytest.mark.parametrize("key,value", [
|
||||
("default_measurement_interval_minutes", "0"),
|
||||
("default_measurement_interval_minutes", "1441"),
|
||||
("default_measurement_interval_minutes", "abc"),
|
||||
("general_tolerance_plus", "-0.1"),
|
||||
("general_tolerance_plus", "0,05"),
|
||||
("general_tolerance_minus", "NaN"),
|
||||
("general_tolerance_minus", "abc"),
|
||||
("list_measurement_types", "not json"),
|
||||
("list_measurement_types", '{"a": 1}'),
|
||||
("list_units", '["mm", ""]'),
|
||||
("list_units", '["mm", 3]'),
|
||||
("list_units", "[" + ",".join(f'"v{i}"' for i in range(51)) + "]"),
|
||||
])
|
||||
async def test_new_keys_invalid_rejected(
|
||||
self, client: AsyncClient, admin_user: User, key: str, value: str
|
||||
):
|
||||
resp = await client.put(
|
||||
"/api/settings/", headers=auth_headers(admin_user), json={key: value}
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
assert key in resp.json()["detail"]
|
||||
|
||||
async def test_unknown_key_not_validated(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
|
||||
@@ -29,30 +29,28 @@ def admin_required(f):
|
||||
@login_required
|
||||
@admin_required
|
||||
def user_list():
|
||||
"""#50: la gestione utenti vive nella tab «Utenti» di Impostazioni."""
|
||||
return redirect(url_for("admin.settings_page", tab="utenti"))
|
||||
"""User management page."""
|
||||
resp = api_client.get("/api/users")
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
flash(_("Errore nel caricamento degli utenti: %(error)s", error=resp.get("detail", "")), "error")
|
||||
users = []
|
||||
elif isinstance(resp, list):
|
||||
users = resp
|
||||
else:
|
||||
users = []
|
||||
|
||||
return render_template("admin/users.html", users=users)
|
||||
|
||||
|
||||
@admin_bp.route("/settings")
|
||||
@login_required
|
||||
@admin_required
|
||||
def settings_page():
|
||||
"""System settings page (admin only), con la tab «Utenti» (#50)."""
|
||||
"""System settings page."""
|
||||
resp = api_client.get("/api/settings")
|
||||
# Solo un dict valido sono impostazioni: un errore o altro tipo = nessuna
|
||||
settings = resp if isinstance(resp, dict) and not resp.get("error") else {}
|
||||
|
||||
users_resp = api_client.get("/api/users")
|
||||
if isinstance(users_resp, dict) and users_resp.get("error"):
|
||||
flash(_("Errore nel caricamento degli utenti: %(error)s", error=users_resp.get("detail", "")), "error")
|
||||
users = []
|
||||
elif isinstance(users_resp, list):
|
||||
users = users_resp
|
||||
else:
|
||||
users = []
|
||||
|
||||
tab = "utenti" if request.args.get("tab") == "utenti" else "generale"
|
||||
return render_template("admin/settings.html", settings=settings, users=users, active_tab=tab)
|
||||
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
|
||||
return render_template("admin/settings.html", settings=settings)
|
||||
|
||||
|
||||
@admin_bp.route("/api/settings", methods=["PUT"])
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||
import urllib.parse
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from flask import (
|
||||
Blueprint, abort, flash, jsonify, redirect, render_template,
|
||||
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
||||
request, session, url_for,
|
||||
)
|
||||
from flask_babel import gettext as _
|
||||
@@ -820,3 +822,67 @@ def api_close_production(run_id: int):
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
return proxy_file(file_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: vision execute (#46) - the browser sends an image, we relay it to
|
||||
# POST /api/vision/execute (multipart) because it cannot send X-API-Key itself.
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/api/vision/execute", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_execute():
|
||||
"""Proxy: run the task's VisionSuite recipe on the uploaded image."""
|
||||
image = request.files.get("image")
|
||||
task_id = request.form.get("task_id")
|
||||
if image is None or not task_id:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
|
||||
}), 400
|
||||
|
||||
# Same context fields as the manual flow; empty ones are left out so the
|
||||
# backend sees them as absent rather than as "".
|
||||
form = {"task_id": task_id}
|
||||
for key in ("task_type", "lot_number", "serial_number", "production_run_id"):
|
||||
val = request.form.get(key)
|
||||
if val:
|
||||
form[key] = val
|
||||
form.setdefault("lot_number", session.get("lot_number", ""))
|
||||
form.setdefault("serial_number", session.get("serial_number", ""))
|
||||
|
||||
resp = api_client.post(
|
||||
"/api/vision/execute",
|
||||
data=form,
|
||||
files={"image": (
|
||||
image.filename or "capture.jpg",
|
||||
image.read(),
|
||||
image.mimetype or "application/octet-stream",
|
||||
)},
|
||||
)
|
||||
if resp.get("error"):
|
||||
status_code = resp.get("status_code", 500)
|
||||
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
|
||||
if resp.get("session_expired"):
|
||||
body["session_expired"] = True
|
||||
return jsonify(body), status_code if status_code >= 400 else 500
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_result_image(result_id: int):
|
||||
"""Proxy: the image saved with a vision result (needs X-API-Key)."""
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/vision/results/{result_id}/image",
|
||||
headers={"X-API-Key": session.get("api_key", "")},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return Response(resp.text, status=resp.status_code)
|
||||
return Response(
|
||||
resp.content,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Vision execute - Alpine component for camera_measure / dxf_compare tasks (#46).
|
||||
*
|
||||
* The operator takes (or picks) an image, `acquire(blob)` sends it to the Flask
|
||||
* proxy, and the result - quote table, outcome, failures, overlay - is shown on
|
||||
* the image. `acquire(blob)` is the single entry point on purpose: the agent
|
||||
* camera (#3) will call it with its own blob and reuse everything below.
|
||||
*
|
||||
* Config comes from window.__visionCfg (set by the template):
|
||||
* executeUrl, imageUrlTpl (with a 0 placeholder for the result id), taskId,
|
||||
* taskType, subtasks, labels.
|
||||
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
||||
* which the template binds to the parent task component.
|
||||
*/
|
||||
function visionExecute() {
|
||||
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||
|
||||
return {
|
||||
busy: false,
|
||||
error: '',
|
||||
result: null, // last execute response
|
||||
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
|
||||
|
||||
// Entry point shared with the future agent camera.
|
||||
async acquire(blob) {
|
||||
if (!blob || this.busy) return;
|
||||
const cfg = window.__visionCfg || {};
|
||||
this.busy = true;
|
||||
this.error = '';
|
||||
this.result = null;
|
||||
// Show the picked image at once: the overlay is drawn on top when the result arrives.
|
||||
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||
this.imageSrc = URL.createObjectURL(blob);
|
||||
try {
|
||||
const c = this.ctx() || {};
|
||||
const fd = new FormData();
|
||||
fd.append('task_id', cfg.taskId);
|
||||
fd.append('task_type', cfg.taskType || '');
|
||||
fd.append('image', blob, blob.name || 'capture.jpg');
|
||||
fd.append('lot_number', c.lot || '');
|
||||
fd.append('serial_number', c.serial || '');
|
||||
if (c.runId) fd.append('production_run_id', c.runId);
|
||||
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const resp = await fetch(cfg.executeUrl, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || body.error) {
|
||||
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
}
|
||||
this.result = body;
|
||||
// Draw once Alpine has rendered the canvas.
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
} catch (e) {
|
||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Input change: take the file and allow picking the same one again.
|
||||
onPick(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
if (f) this.acquire(f);
|
||||
},
|
||||
|
||||
// Image shown: the saved one from the server when available, else the local one.
|
||||
get shownImage() {
|
||||
const cfg = window.__visionCfg || {};
|
||||
if (this.result && this.result.vision_result_id && cfg.imageUrlTpl) {
|
||||
return cfg.imageUrlTpl.replace('/0/', '/' + this.result.vision_result_id + '/');
|
||||
}
|
||||
return this.imageSrc;
|
||||
},
|
||||
|
||||
// Quote rows: server measurements joined with the subtask names.
|
||||
get rows() {
|
||||
const cfg = window.__visionCfg || {};
|
||||
const subs = cfg.subtasks || [];
|
||||
return ((this.result && this.result.measurements) || []).map((m) => {
|
||||
const st = subs.find((s) => s.id === m.subtask_id) || {};
|
||||
return {
|
||||
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
|
||||
value: m.value,
|
||||
unit: st.unit || 'mm',
|
||||
status: m.pass_fail,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
statusClass(s) {
|
||||
// Same palette as the measurement markers of the manual flow.
|
||||
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
|
||||
if (s === 'fail') return 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40';
|
||||
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
|
||||
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
|
||||
},
|
||||
|
||||
// Canvas is sized to the natural image, so overlay coordinates (image px)
|
||||
// are used as they are and CSS scales the whole thing to the displayed size.
|
||||
drawOverlay() {
|
||||
const img = this.$refs.img, cv = this.$refs.overlay;
|
||||
if (!img || !cv || !img.naturalWidth) return;
|
||||
cv.width = img.naturalWidth;
|
||||
cv.height = img.naturalHeight;
|
||||
const g = cv.getContext('2d');
|
||||
g.clearRect(0, 0, cv.width, cv.height);
|
||||
g.lineWidth = Math.max(2, cv.width / 400);
|
||||
g.lineJoin = 'round';
|
||||
for (const item of ((this.result && this.result.overlay) || [])) {
|
||||
const rows = item.rows || [], cols = item.cols || [];
|
||||
if (!rows.length || rows.length !== cols.length) continue;
|
||||
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
|
||||
g.beginPath();
|
||||
g.moveTo(cols[0], rows[0]);
|
||||
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
|
||||
g.stroke();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
<script>
|
||||
function userManagement(initialUsers) {
|
||||
return {
|
||||
users: initialUsers || [],
|
||||
search: '',
|
||||
showModal: false,
|
||||
showToggleConfirm: false,
|
||||
// #51: stato del reset password
|
||||
showReset: false,
|
||||
resetUser: null,
|
||||
resetPw: '',
|
||||
resetDone: false,
|
||||
resetError: '',
|
||||
isEditing: false,
|
||||
editingUserId: null,
|
||||
toggleUser: null,
|
||||
saving: false,
|
||||
errorMsg: '',
|
||||
form: {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
},
|
||||
|
||||
get csrfToken() {
|
||||
const meta = document.querySelector('meta[name=csrf-token]');
|
||||
return meta ? meta.content : '';
|
||||
},
|
||||
|
||||
get filteredUsers() {
|
||||
if (!this.search.trim()) return this.users;
|
||||
const q = this.search.toLowerCase();
|
||||
return this.users.filter(u =>
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
|
||||
(u.email && u.email.toLowerCase().includes(q)) ||
|
||||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
|
||||
);
|
||||
},
|
||||
|
||||
openCreateModal() {
|
||||
this.isEditing = false;
|
||||
this.editingUserId = null;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
openEditModal(user) {
|
||||
this.isEditing = true;
|
||||
this.editingUserId = user.id;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: user.username,
|
||||
display_name: user.display_name || '',
|
||||
email: user.email || '',
|
||||
password: '',
|
||||
roles: [...(user.roles || [])],
|
||||
is_admin: user.is_admin || false,
|
||||
language_pref: user.language_pref || 'it',
|
||||
theme_pref: user.theme_pref || 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.errorMsg = '';
|
||||
},
|
||||
|
||||
async saveUser() {
|
||||
this.saving = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
try {
|
||||
if (this.isEditing) {
|
||||
// Update user
|
||||
const data = {
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nel salvataggio';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.editingUserId);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
// Change password if provided
|
||||
if (this.form.password) {
|
||||
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ password: this.form.password })
|
||||
});
|
||||
if (!pwResp.ok) {
|
||||
const pwResult = await pwResp.json();
|
||||
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
if (!this.form.username || !this.form.display_name || !this.form.password) {
|
||||
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
username: this.form.username,
|
||||
password: this.form.password,
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch('/admin/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nella creazione';
|
||||
return;
|
||||
}
|
||||
|
||||
this.users.push(result);
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// #51: reset password dall'admin, usa PUT /admin/api/users/<id>/password
|
||||
openReset(user) {
|
||||
this.resetUser = user;
|
||||
this.resetPw = '';
|
||||
this.resetDone = false;
|
||||
this.resetError = '';
|
||||
this.showReset = true;
|
||||
},
|
||||
|
||||
closeReset() {
|
||||
this.showReset = false;
|
||||
this.resetPw = ''; // la password generata non resta in memoria
|
||||
this.resetDone = false;
|
||||
},
|
||||
|
||||
generateResetPw() {
|
||||
// 12 caratteri senza simboli ambigui (0/O, 1/l/I), da crypto random
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||
const buf = new Uint32Array(12);
|
||||
crypto.getRandomValues(buf);
|
||||
this.resetPw = Array.from(buf, n => chars[n % chars.length]).join('');
|
||||
this.resetError = '';
|
||||
},
|
||||
|
||||
async confirmReset() {
|
||||
if (!this.resetPw || this.resetPw.length < 8) {
|
||||
this.resetError = {{ _('La password deve avere almeno 8 caratteri')|tojson }};
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.resetError = '';
|
||||
try {
|
||||
const resp = await fetch(`/admin/api/users/${this.resetUser.id}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ password: this.resetPw })
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.resetError = result.detail || {{ _('Errore nel cambio password')|tojson }};
|
||||
return;
|
||||
}
|
||||
this.resetDone = true;
|
||||
} catch (e) {
|
||||
this.resetError = {{ _('Errore di connessione al server')|tojson }};
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
toggleActive(user) {
|
||||
this.toggleUser = user;
|
||||
this.showToggleConfirm = true;
|
||||
},
|
||||
|
||||
async confirmToggle() {
|
||||
if (!this.toggleUser) return;
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const newActive = !this.toggleUser.active;
|
||||
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ active: newActive })
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
alert(result.detail || 'Errore');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
this.showToggleConfirm = false;
|
||||
this.toggleUser = null;
|
||||
} catch (e) {
|
||||
alert('{{ _("Errore di connessione al server") }}');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -3,31 +3,17 @@
|
||||
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tmf-page" x-data="{ tab: {{ active_tab|tojson_attr }} }">
|
||||
<div class="tmf-page tmf-page-narrow"
|
||||
x-data="systemSettings()">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-4">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- #50: tab. L'intera pagina e' gia' solo per admin (admin_required) -->
|
||||
<div class="flex gap-1 mb-6 border-b border-[var(--border-color)]">
|
||||
<button type="button" @click="tab = 'generale'"
|
||||
:class="tab === 'generale' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
|
||||
class="px-4 py-2 -mb-px text-sm font-medium border-b-2 transition-colors">{{ _('Generale') }}</button>
|
||||
<button type="button" @click="tab = 'utenti'"
|
||||
:class="tab === 'utenti' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
|
||||
class="px-4 py-2 -mb-px text-sm font-medium border-b-2 transition-colors">{{ _('Utenti') }}</button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'utenti'" x-cloak>
|
||||
{% include "admin/_users_panel.html" %}
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'generale'" class="max-w-4xl mx-auto" x-data="systemSettings()">
|
||||
<!-- Settings Card -->
|
||||
<div class="tmf-card mb-6">
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-header flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
@@ -57,57 +43,6 @@
|
||||
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- #47: durata loop predefinita delle ricette -->
|
||||
<div class="pt-5 border-t border-[var(--border-color)]">
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Ricette') }}</h3>
|
||||
<label class="tmf-label" for="default-interval">{{ _('Durata loop predefinita (minuti)') }}</label>
|
||||
<input id="default-interval" type="number" x-model="defaultInterval" class="tmf-input w-32" min="1" max="1440" placeholder="—">
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Proposta come intervallo di misura nelle nuove ricette (1-1440). Vuoto = nessun predefinito.') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- #48: tolleranze generali -->
|
||||
<div class="pt-5 border-t border-[var(--border-color)]">
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Tolleranze generali') }}</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<div>
|
||||
<label class="tmf-label" for="tol-plus">{{ _('Tolleranza +') }}</label>
|
||||
<input id="tol-plus" type="text" inputmode="decimal" x-model="tolPlus" class="tmf-input w-32" placeholder="—">
|
||||
</div>
|
||||
<div>
|
||||
<label class="tmf-label" for="tol-minus">{{ _('Tolleranza −') }}</label>
|
||||
<input id="tol-minus" type="text" inputmode="decimal" x-model="tolMinus" class="tmf-input w-32" placeholder="—">
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Valori positivi (zero o più) proposti come tolleranza nelle nuove misure. Vuoto = nessun predefinito.') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- #49: anagrafiche (liste di scelta) -->
|
||||
<div class="pt-5 border-t border-[var(--border-color)]">
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Anagrafiche') }}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<template x-for="lst in ['types', 'units']" :key="lst">
|
||||
<div>
|
||||
<label class="tmf-label" x-text="lst === 'types' ? {{ _('Tipi di misura')|tojson_attr }} : {{ _('Unità di misura')|tojson_attr }}"></label>
|
||||
<ul class="space-y-1 mb-2">
|
||||
<template x-for="(item, i) in lists[lst]" :key="lst + i">
|
||||
<li class="flex items-center gap-1 text-sm">
|
||||
<span class="flex-1 px-2 py-1 rounded bg-[var(--bg-secondary)] text-[var(--text-primary)]" x-text="item"></span>
|
||||
<button type="button" @click="moveItem(lst, i, -1)" :disabled="i === 0" class="px-1.5 text-[var(--text-muted)] hover:text-primary disabled:opacity-30" title="{{ _('Su') }}">▲</button>
|
||||
<button type="button" @click="moveItem(lst, i, 1)" :disabled="i === lists[lst].length - 1" class="px-1.5 text-[var(--text-muted)] hover:text-primary disabled:opacity-30" title="{{ _('Giù') }}">▼</button>
|
||||
<button type="button" @click="removeItem(lst, i)" class="px-1.5 text-[var(--text-muted)] hover:text-red-500" title="{{ _('Rimuovi') }}">✕</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" x-model="newItem[lst]" @keydown.enter.prevent="addItem(lst)" class="tmf-input flex-1" placeholder="{{ _('Nuova voce') }}">
|
||||
<button type="button" @click="addItem(lst)" class="btn btn-secondary">{{ _('Aggiungi') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-muted)] mt-2">{{ _('Massimo 50 voci per lista. Un elenco vuoto = nessuna scelta predefinita.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tmf-card-footer flex items-center justify-between">
|
||||
@@ -133,48 +68,18 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab generale -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// #49: legge un array JSON di stringhe salvato come impostazione; vuoto/malformato = lista vuota
|
||||
function tmfParseList(raw) {
|
||||
try { var a = JSON.parse(raw || '[]'); return Array.isArray(a) ? a.map(String) : []; }
|
||||
catch (e) { return []; }
|
||||
}
|
||||
function systemSettings() {
|
||||
return {
|
||||
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
|
||||
// #47-#49: nuove chiavi (valori stringa, vuoto = nessun default)
|
||||
defaultInterval: {{ (settings.default_measurement_interval_minutes or '')|tojson }},
|
||||
tolPlus: {{ (settings.general_tolerance_plus or '')|tojson }},
|
||||
tolMinus: {{ (settings.general_tolerance_minus or '')|tojson }},
|
||||
lists: {
|
||||
types: tmfParseList({{ (settings.list_measurement_types or '')|tojson }}),
|
||||
units: tmfParseList({{ (settings.list_units or '')|tojson }})
|
||||
},
|
||||
newItem: { types: '', units: '' },
|
||||
saving: false,
|
||||
saved: false,
|
||||
errorMessage: '',
|
||||
|
||||
addItem(lst) {
|
||||
var v = (this.newItem[lst] || '').trim();
|
||||
if (!v) return;
|
||||
if (this.lists[lst].length >= 50) { this.errorMessage = {{ _('Massimo 50 voci per lista')|tojson }}; return; }
|
||||
if (this.lists[lst].indexOf(v) === -1) this.lists[lst].push(v);
|
||||
this.newItem[lst] = '';
|
||||
},
|
||||
removeItem(lst, i) { this.lists[lst].splice(i, 1); },
|
||||
moveItem(lst, i, d) {
|
||||
var a = this.lists[lst].slice(), j = i + d;
|
||||
if (j < 0 || j >= a.length) return;
|
||||
var t = a[i]; a[i] = a[j]; a[j] = t;
|
||||
this.lists[lst] = a;
|
||||
},
|
||||
|
||||
async save() {
|
||||
this.saving = true;
|
||||
this.saved = false;
|
||||
@@ -182,13 +87,7 @@ function systemSettings() {
|
||||
|
||||
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
|
||||
var payload = {
|
||||
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : '',
|
||||
default_measurement_interval_minutes: String(this.defaultInterval || '').trim(),
|
||||
// virgola decimale accettata in input, il backend vuole il punto
|
||||
general_tolerance_plus: String(this.tolPlus || '').trim().replace(',', '.'),
|
||||
general_tolerance_minus: String(this.tolMinus || '').trim().replace(',', '.'),
|
||||
list_measurement_types: this.lists.types.length ? JSON.stringify(this.lists.types) : '',
|
||||
list_units: this.lists.units.length ? JSON.stringify(this.lists.units) : ''
|
||||
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : ''
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -212,5 +111,4 @@ function systemSettings() {
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% include "admin/_users_script.html" %}
|
||||
{% endblock %}
|
||||
|
||||
+219
-55
@@ -1,7 +1,18 @@
|
||||
{# Pannello utenti: incluso nella tab «Utenti» di Impostazioni (#50) #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Gestione Utenti') }} - TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>window.__users = {{ users|tojson }};</script>
|
||||
|
||||
<div x-data="userManagement(window.__users)">
|
||||
<div class="tmf-page"
|
||||
x-data="userManagement(window.__users)">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Utenti') }}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci gli utenti del sistema') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
|
||||
@@ -91,12 +102,6 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- #51: reset password dall'admin -->
|
||||
<button @click="openReset(user)" class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="{{ _('Reset password')|tojson_attr }}">
|
||||
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
|
||||
:class="user.active
|
||||
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
@@ -327,51 +332,210 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #51: Modal reset password (la password attuale non si mostra mai) -->
|
||||
<div x-show="showReset" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape.window="closeReset()">
|
||||
<div class="absolute inset-0 bg-black/50" @click="closeReset()"></div>
|
||||
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{{ _('Reset password') }}: <span x-text="resetUser?.username"></span>
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
{{ _('Le sessioni aperte dell\'utente verranno chiuse e dovrà accedere con la nuova password.') }}
|
||||
</p>
|
||||
<template x-if="!resetDone">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuova password (minimo 8 caratteri)') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" x-model="resetPw" autocomplete="off"
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
|
||||
<button @click="generateResetPw()" type="button"
|
||||
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
|
||||
{{ _('Genera') }}
|
||||
</button>
|
||||
</div>
|
||||
<template x-if="resetError">
|
||||
<p class="mt-2 text-sm text-red-600" x-text="resetError"></p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="resetDone">
|
||||
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
|
||||
<p class="text-sm text-emerald-700 dark:text-emerald-300">{{ _('Password cambiata. Annotala ora: non verrà mostrata di nuovo.') }}</p>
|
||||
<p class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="resetPw"></p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button @click="closeReset()"
|
||||
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
|
||||
<span x-text="resetDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
|
||||
</button>
|
||||
<button x-show="!resetDone" @click="confirmReset()" :disabled="saving"
|
||||
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
|
||||
{{ _('Imposta password') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function userManagement(initialUsers) {
|
||||
return {
|
||||
users: initialUsers || [],
|
||||
search: '',
|
||||
showModal: false,
|
||||
showToggleConfirm: false,
|
||||
isEditing: false,
|
||||
editingUserId: null,
|
||||
toggleUser: null,
|
||||
saving: false,
|
||||
errorMsg: '',
|
||||
form: {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
},
|
||||
|
||||
get csrfToken() {
|
||||
const meta = document.querySelector('meta[name=csrf-token]');
|
||||
return meta ? meta.content : '';
|
||||
},
|
||||
|
||||
get filteredUsers() {
|
||||
if (!this.search.trim()) return this.users;
|
||||
const q = this.search.toLowerCase();
|
||||
return this.users.filter(u =>
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
|
||||
(u.email && u.email.toLowerCase().includes(q)) ||
|
||||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
|
||||
);
|
||||
},
|
||||
|
||||
openCreateModal() {
|
||||
this.isEditing = false;
|
||||
this.editingUserId = null;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
openEditModal(user) {
|
||||
this.isEditing = true;
|
||||
this.editingUserId = user.id;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: user.username,
|
||||
display_name: user.display_name || '',
|
||||
email: user.email || '',
|
||||
password: '',
|
||||
roles: [...(user.roles || [])],
|
||||
is_admin: user.is_admin || false,
|
||||
language_pref: user.language_pref || 'it',
|
||||
theme_pref: user.theme_pref || 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.errorMsg = '';
|
||||
},
|
||||
|
||||
async saveUser() {
|
||||
this.saving = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
try {
|
||||
if (this.isEditing) {
|
||||
// Update user
|
||||
const data = {
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nel salvataggio';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.editingUserId);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
// Change password if provided
|
||||
if (this.form.password) {
|
||||
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ password: this.form.password })
|
||||
});
|
||||
if (!pwResp.ok) {
|
||||
const pwResult = await pwResp.json();
|
||||
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
if (!this.form.username || !this.form.display_name || !this.form.password) {
|
||||
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
username: this.form.username,
|
||||
password: this.form.password,
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch('/admin/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nella creazione';
|
||||
return;
|
||||
}
|
||||
|
||||
this.users.push(result);
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
toggleActive(user) {
|
||||
this.toggleUser = user;
|
||||
this.showToggleConfirm = true;
|
||||
},
|
||||
|
||||
async confirmToggle() {
|
||||
if (!this.toggleUser) return;
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const newActive = !this.toggleUser.active;
|
||||
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ active: newActive })
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
alert(result.detail || 'Errore');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
this.showToggleConfirm = false;
|
||||
this.toggleUser = null;
|
||||
} catch (e) {
|
||||
alert('{{ _("Errore di connessione al server") }}');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -32,6 +32,7 @@
|
||||
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
|
||||
{% endif %}
|
||||
{% if current_user.get('is_admin') %}
|
||||
{{ nav_link(url_for('admin.user_list'), _('Utenti'), icon_users, ep == 'admin.user_list', mobile) }}
|
||||
{{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_list', mobile) }}
|
||||
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -191,7 +191,8 @@
|
||||
{# ================================================================
|
||||
MAIN — 3-column layout
|
||||
================================================================ #}
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
{# #46: camera_measure / dxf_compare get the acquisition panel below instead of the manual layout #}
|
||||
<div class="flex-1 flex overflow-hidden" x-show="!isVisionTask">
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
|
||||
@@ -451,6 +452,63 @@
|
||||
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
VISION PANEL (#46) - acquire an image, run the recipe server-side, show the
|
||||
quotes and the overlay. acquire(blob) is the entry point the agent camera
|
||||
(#3) will reuse.
|
||||
================================================================ #}
|
||||
<div x-show="isVisionTask" x-cloak
|
||||
x-data="visionExecute()"
|
||||
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
|
||||
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
||||
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
|
||||
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
|
||||
<input type="file" accept="image/*" capture="environment" class="hidden"
|
||||
@change="onPick($event)" :disabled="busy">
|
||||
</label>
|
||||
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
||||
</div>
|
||||
|
||||
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
|
||||
|
||||
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
||||
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||
</div>
|
||||
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
||||
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
|
||||
<div class="text-sm font-bold text-[var(--text-primary)]">
|
||||
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm" x-show="rows.length">
|
||||
<template x-for="r in rows" :key="r.label">
|
||||
<tr>
|
||||
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
|
||||
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
|
||||
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
|
||||
<div x-show="result && result.failures && result.failures.length"
|
||||
class="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-xs">
|
||||
<div class="font-bold mb-1">{{ _('Strumenti falliti') }}</div>
|
||||
<template x-for="f in ((result && result.failures) || [])" :key="f.tool_id">
|
||||
<div><span class="font-mono" x-text="f.tool_name || f.tool_id"></span>: <span x-text="f.error"></span></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
MEASUREMENT TIMER BANNER
|
||||
================================================================ #}
|
||||
@@ -912,6 +970,18 @@
|
||||
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
|
||||
{# #46: acquisition + overlay for camera_measure / dxf_compare #}
|
||||
<script>
|
||||
window.__visionCfg = {
|
||||
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
||||
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||
taskId: {{ task.id|tojson }},
|
||||
taskType: {{ (task.task_type or '')|tojson }},
|
||||
subtasks: window.__taskSubtasks,
|
||||
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
||||
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
||||
measurement lives - are shared with the other screens rather than restated. #}
|
||||
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
||||
@@ -1776,6 +1846,12 @@ function taskExecute() {
|
||||
return declared === 'measure' || declared === 'camera_measure';
|
||||
},
|
||||
|
||||
/* #46: image-based tasks run a VisionSuite recipe on the server. */
|
||||
get isVisionTask() {
|
||||
const t = this.task.task_type;
|
||||
return t === 'camera_measure' || t === 'dxf_compare';
|
||||
},
|
||||
|
||||
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
||||
loud rather than silently rendering an empty screen. */
|
||||
get isMeasureTaskWithoutQuotes() {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""#50/#51: tab Utenti dentro Impostazioni, redirect da /admin/users, reset password."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import api_get_router
|
||||
|
||||
USER = {
|
||||
"id": 2, "username": "mario", "display_name": "Mario", "email": None,
|
||||
"roles": ["MeasurementTec"], "is_admin": False, "language_pref": "it",
|
||||
"theme_pref": "light", "active": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_admin_api():
|
||||
mock = MagicMock()
|
||||
mock.get.side_effect = api_get_router({
|
||||
"/api/users": [USER],
|
||||
"/api/settings": {"auto_logout_minutes": "30"},
|
||||
})
|
||||
with patch("blueprints.admin.api_client", mock):
|
||||
yield mock
|
||||
|
||||
|
||||
def test_users_url_redirects_to_settings_tab(logged_in_client, mock_admin_api):
|
||||
resp = logged_in_client.get("/admin/users")
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith("/admin/settings?tab=utenti")
|
||||
|
||||
|
||||
def test_settings_has_users_tab_and_reset_for_admin(logged_in_client, mock_admin_api):
|
||||
resp = logged_in_client.get("/admin/settings?tab=utenti")
|
||||
assert resp.status_code == 200
|
||||
html = resp.data.decode("utf-8")
|
||||
assert "userManagement(" in html # pannello utenti incluso
|
||||
assert "openReset(user)" in html # pulsante reset password (#51)
|
||||
assert "mario" in html # utente caricato
|
||||
assert "tab: " in html and "utenti" in html # tab iniziale richiesta
|
||||
|
||||
|
||||
def test_navbar_has_no_users_link(logged_in_client, mock_admin_api):
|
||||
html = logged_in_client.get("/admin/settings").data.decode("utf-8")
|
||||
assert 'href="/admin/users"' not in html
|
||||
|
||||
|
||||
def test_non_admin_cannot_reach_users_tab(client, mock_admin_api):
|
||||
with client.session_transaction() as sess:
|
||||
sess["api_key"] = "k"
|
||||
sess["user"] = {"id": 3, "username": "op", "roles": ["MeasurementTec"],
|
||||
"is_admin": False, "language_pref": "it", "theme_pref": "light"}
|
||||
for url in ("/admin/settings?tab=utenti", "/admin/users"):
|
||||
resp = client.get(url)
|
||||
assert resp.status_code == 302
|
||||
assert "/admin/" not in resp.headers["Location"]
|
||||
mock_admin_api.get.assert_not_called()
|
||||
|
||||
|
||||
def test_password_reset_proxy(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.put.return_value = {"message": "ok"}
|
||||
resp = logged_in_client.put("/admin/api/users/2/password", json={"password": "Nuova1234"})
|
||||
assert resp.status_code == 200
|
||||
mock_admin_api.put.assert_called_once_with(
|
||||
"/api/users/2/password", data={"password": "Nuova1234"})
|
||||
@@ -0,0 +1,86 @@
|
||||
"""gitea:Adriano/TieMeasureFlow#46 - operator side of the vision tasks.
|
||||
|
||||
The backend of contract B is not merged yet: api_client is mocked.
|
||||
"""
|
||||
import io
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.conftest import api_get_router
|
||||
|
||||
TASK = {
|
||||
"id": 12, "recipe_id": 3, "version_id": 5, "task_type": "camera_measure",
|
||||
"title": "Foto", "directive": None, "description": None, "file_path": None,
|
||||
"file_type": None, "annotations_json": None, "subtasks": [],
|
||||
}
|
||||
RECIPE = {
|
||||
"id": 3, "code": "REC-1", "name": "Ricetta",
|
||||
"measurement_interval_minutes": 30, "allow_manual_input": True,
|
||||
"current_version": {"id": 5, "version_number": 1},
|
||||
}
|
||||
|
||||
|
||||
def test_execute_page_has_acquire_input(logged_in_client, mock_api_client, monkeypatch):
|
||||
"""camera_measure screen offers the file/camera input and the acquire entry point."""
|
||||
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/tasks/12": TASK,
|
||||
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
|
||||
"/api/recipes/3": RECIPE,
|
||||
})
|
||||
html = logged_in_client.get("/measure/execute/12").get_data(as_text=True)
|
||||
assert 'capture="environment"' in html
|
||||
assert 'accept="image/*"' in html
|
||||
assert "visionExecute()" in html
|
||||
assert "/measure/api/vision/execute" in html
|
||||
|
||||
|
||||
def test_vision_execute_proxy_forwards_multipart(logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = {
|
||||
"vision_result_id": 7, "outcome": "ok", "overlay": [], "failures": [],
|
||||
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
|
||||
}
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/vision/execute",
|
||||
data={
|
||||
"task_id": "12", "lot_number": "L1", "serial_number": "S1",
|
||||
"production_run_id": "4",
|
||||
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["vision_result_id"] == 7
|
||||
args, kwargs = mock_api_client.post.call_args
|
||||
assert args[0] == "/api/vision/execute"
|
||||
assert kwargs["data"]["task_id"] == "12"
|
||||
assert kwargs["data"]["lot_number"] == "L1"
|
||||
assert kwargs["data"]["production_run_id"] == "4"
|
||||
name, content, mime = kwargs["files"]["image"]
|
||||
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
|
||||
|
||||
|
||||
def test_vision_execute_proxy_requires_image(logged_in_client, mock_api_client):
|
||||
resp = logged_in_client.post("/measure/api/vision/execute", data={"task_id": "12"})
|
||||
assert resp.status_code == 400
|
||||
mock_api_client.post.assert_not_called()
|
||||
|
||||
|
||||
def test_vision_execute_proxy_relays_backend_error(logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "bad"}
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/vision/execute",
|
||||
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.get_json()["detail"] == "bad"
|
||||
|
||||
|
||||
def test_vision_result_image_proxy(logged_in_client):
|
||||
fake = MagicMock(status_code=200, content=b"IMG", headers={"content-type": "image/jpeg"})
|
||||
with patch("blueprints.measure.http_requests.get", return_value=fake) as g:
|
||||
resp = logged_in_client.get("/measure/api/vision/results/7/image")
|
||||
assert resp.status_code == 200 and resp.data == b"IMG"
|
||||
assert g.call_args[0][0].endswith("/api/vision/results/7/image")
|
||||
assert g.call_args[1]["headers"]["X-API-Key"] == "test-api-key-12345"
|
||||
@@ -256,7 +256,7 @@ def test_admin_stations_inline_js_is_valid_in_english(
|
||||
|
||||
|
||||
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||
"""Same guard for the Users tab, now inside /admin/settings (#50)."""
|
||||
"""Same guard for /admin/users (reuses the userManagement Alpine pattern)."""
|
||||
mock_admin_api.get.return_value = [{
|
||||
"id": 1, "username": "admin", "display_name": "Admin",
|
||||
"email": None, "roles": ["Maker"], "is_admin": True,
|
||||
@@ -264,7 +264,7 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||
}]
|
||||
|
||||
_force_italian(logged_in_client)
|
||||
resp = logged_in_client.get("/admin/settings")
|
||||
resp = logged_in_client.get("/admin/users")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.data.decode("utf-8")
|
||||
@@ -272,9 +272,9 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||
assert scripts
|
||||
|
||||
for i, body in enumerate(scripts):
|
||||
_node_check(body, f"/admin/settings script[{i}]")
|
||||
_node_check(body, f"/admin/users script[{i}]")
|
||||
|
||||
_check_alpine_attributes(html, "/admin/settings")
|
||||
_check_alpine_attributes(html, "/admin/users")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
Reference in New Issue
Block a user