From 23bfc93f1c93aa6f052f5b56780646c051ac6790 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 12:28:31 +0200 Subject: [PATCH] Impostazioni: durata loop, tolleranze, anagrafiche; tab Utenti con reset password (#47 #48 #49 #50 #51) Co-Authored-By: Claude Sonnet 5.5 --- src/backend/api/routers/settings.py | 43 +++ src/backend/tests/test_settings.py | 44 +++ src/frontend/flask_app/blueprints/admin.py | 32 +- .../admin/{users.html => _users_panel.html} | 274 ++++-------------- .../templates/admin/_users_script.html | 258 +++++++++++++++++ .../flask_app/templates/admin/settings.html | 112 ++++++- .../templates/components/navbar.html | 1 - .../flask_app/tests/test_admin_users_tab.py | 64 ++++ .../tests/test_template_js_syntax.py | 8 +- 9 files changed, 592 insertions(+), 244 deletions(-) rename src/frontend/flask_app/templates/admin/{users.html => _users_panel.html} (75%) create mode 100644 src/frontend/flask_app/templates/admin/_users_script.html create mode 100644 src/frontend/flask_app/tests/test_admin_users_tab.py 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) #} -
- - -
-

{{ _('Gestione Utenti') }}

-

{{ _('Crea, modifica e gestisci gli utenti del sistema') }}

-
+
@@ -102,6 +91,12 @@ + + + +
+
+
+ -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/src/frontend/flask_app/templates/admin/_users_script.html b/src/frontend/flask_app/templates/admin/_users_script.html new file mode 100644 index 0000000..36449dd --- /dev/null +++ b/src/frontend/flask_app/templates/admin/_users_script.html @@ -0,0 +1,258 @@ + diff --git a/src/frontend/flask_app/templates/admin/settings.html b/src/frontend/flask_app/templates/admin/settings.html index 4b94aee..4b72b73 100644 --- a/src/frontend/flask_app/templates/admin/settings.html +++ b/src/frontend/flask_app/templates/admin/settings.html @@ -3,17 +3,31 @@ {% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %} {% block content %} -
+
-
+

{{ _('Impostazioni di Sistema') }}

{{ _('Configura i parametri generali del sistema') }}

+ +
+ + +
+ +
+ {% include "admin/_users_panel.html" %} +
+ +
-
+
@@ -43,6 +57,57 @@ {{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}

+ + +
+

{{ _('Ricette') }}

+ + +

{{ _('Proposta come intervallo di misura nelle nuove ricette (1-1440). Vuoto = nessun predefinito.') }}

+
+ + +
+

{{ _('Tolleranze generali') }}

+
+
+ + +
+
+ + +
+
+

{{ _('Valori positivi (zero o più) proposti come tolleranza nelle nuove misure. Vuoto = nessun predefinito.') }}

+
+ + +
+

{{ _('Anagrafiche') }}

+
+ +
+

{{ _('Massimo 50 voci per lista. Un elenco vuoto = nessuna scelta predefinita.') }}

+
+
{% endblock %} {% block extra_js %} +{% include "admin/_users_script.html" %} {% endblock %} diff --git a/src/frontend/flask_app/templates/components/navbar.html b/src/frontend/flask_app/templates/components/navbar.html index 0be59ad..b6c26eb 100644 --- a/src/frontend/flask_app/templates/components/navbar.html +++ b/src/frontend/flask_app/templates/components/navbar.html @@ -32,7 +32,6 @@ {{ 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 %} diff --git a/src/frontend/flask_app/tests/test_admin_users_tab.py b/src/frontend/flask_app/tests/test_admin_users_tab.py new file mode 100644 index 0000000..dca1ee0 --- /dev/null +++ b/src/frontend/flask_app/tests/test_admin_users_tab.py @@ -0,0 +1,64 @@ +"""#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"}) diff --git a/src/frontend/flask_app/tests/test_template_js_syntax.py b/src/frontend/flask_app/tests/test_template_js_syntax.py index c45c78a..4b8ba92 100644 --- a/src/frontend/flask_app/tests/test_template_js_syntax.py +++ b/src/frontend/flask_app/tests/test_template_js_syntax.py @@ -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 /admin/users (reuses the userManagement Alpine pattern).""" + """Same guard for the Users tab, now inside /admin/settings (#50).""" 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/users") + resp = logged_in_client.get("/admin/settings") 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/users script[{i}]") + _node_check(body, f"/admin/settings script[{i}]") - _check_alpine_attributes(html, "/admin/users") + _check_alpine_attributes(html, "/admin/settings") @pytest.fixture