diff --git a/src/backend/api/routers/settings.py b/src/backend/api/routers/settings.py index c58fdab..cd5b640 100644 --- a/src/backend/api/routers/settings.py +++ b/src/backend/api/routers/settings.py @@ -1,4 +1,7 @@ """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 @@ -18,6 +21,23 @@ _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. @@ -41,6 +61,29 @@ 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( diff --git a/src/backend/tests/test_settings.py b/src/backend/tests/test_settings.py index ece91c3..dd5de62 100644 --- a/src/backend/tests/test_settings.py +++ b/src/backend/tests/test_settings.py @@ -176,6 +176,50 @@ 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 ): diff --git a/src/frontend/flask_app/blueprints/admin.py b/src/frontend/flask_app/blueprints/admin.py index 940a57f..2658066 100644 --- a/src/frontend/flask_app/blueprints/admin.py +++ b/src/frontend/flask_app/blueprints/admin.py @@ -29,28 +29,30 @@ def admin_required(f): @login_required @admin_required def user_list(): - """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) + """#50: la gestione utenti vive nella tab «Utenti» di Impostazioni.""" + return redirect(url_for("admin.settings_page", tab="utenti")) @admin_bp.route("/settings") @login_required @admin_required def settings_page(): - """System settings page.""" + """System settings page (admin only), con la tab «Utenti» (#50).""" resp = api_client.get("/api/settings") - settings = resp if not isinstance(resp, dict) or not resp.get("error") else {} - return render_template("admin/settings.html", settings=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) @admin_bp.route("/api/settings", methods=["PUT"]) diff --git a/src/frontend/flask_app/templates/admin/users.html b/src/frontend/flask_app/templates/admin/_users_panel.html similarity index 75% rename from src/frontend/flask_app/templates/admin/users.html rename to src/frontend/flask_app/templates/admin/_users_panel.html index acf018b..68e1ec4 100644 --- a/src/frontend/flask_app/templates/admin/users.html +++ b/src/frontend/flask_app/templates/admin/_users_panel.html @@ -1,18 +1,7 @@ -{% extends "base.html" %} - -{% block title %}{{ _('Gestione Utenti') }} - TieMeasureFlow{% endblock %} - -{% block content %} +{# Pannello utenti: incluso nella tab «Utenti» di Impostazioni (#50) #} -
{{ _('Crea, modifica e gestisci gli utenti del sistema') }}
-{{ _('Configura i parametri generali del sistema') }}
{{ _('Proposta come intervallo di misura nelle nuove ricette (1-1440). Vuoto = nessun predefinito.') }}
+{{ _('Valori positivi (zero o più) proposti come tolleranza nelle nuove misure. Vuoto = nessun predefinito.') }}
+{{ _('Massimo 50 voci per lista. Un elenco vuoto = nessuna scelta predefinita.') }}
+