diff --git a/client/blueprints/admin.py b/client/blueprints/admin.py index c2abaec..2b38fe3 100644 --- a/client/blueprints/admin.py +++ b/client/blueprints/admin.py @@ -43,6 +43,31 @@ def user_list(): return render_template("admin/users.html", users=users) +@admin_bp.route("/stations") +@login_required +@admin_required +def station_list(): + """Station management page.""" + resp = api_client.get("/api/stations") + if isinstance(resp, dict) and resp.get("error"): + flash(_("Errore nel caricamento delle stazioni: %(error)s", error=resp.get("detail", "")), "error") + stations = [] + elif isinstance(resp, list): + stations = resp + else: + stations = [] + + recipes_resp = api_client.get("/api/recipes") + if isinstance(recipes_resp, list): + all_recipes = recipes_resp + elif isinstance(recipes_resp, dict) and isinstance(recipes_resp.get("items"), list): + all_recipes = recipes_resp["items"] + else: + all_recipes = [] + + return render_template("admin/stations.html", stations=stations, all_recipes=all_recipes) + + # ============================================================================ # API PROXY AJAX (JSON) # ============================================================================ @@ -102,3 +127,74 @@ def api_toggle_active(user_id: int): return jsonify(resp), resp.get("status_code", 500) return jsonify(resp), 200 + + +# --- Stations --- + +@admin_bp.route("/api/stations", methods=["POST"]) +@login_required +@admin_required +def api_create_station(): + """Proxy: Create a new station.""" + data = request.get_json(silent=True) or {} + resp = api_client.post("/api/stations", data=data) + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 201 + + +@admin_bp.route("/api/stations/", methods=["PUT"]) +@login_required +@admin_required +def api_update_station(station_id: int): + """Proxy: Update a station.""" + data = request.get_json(silent=True) or {} + resp = api_client.put(f"/api/stations/{station_id}", data=data) + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 200 + + +@admin_bp.route("/api/stations/", methods=["DELETE"]) +@login_required +@admin_required +def api_delete_station(station_id: int): + """Proxy: Delete a station.""" + resp = api_client.delete(f"/api/stations/{station_id}") + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify({"deleted": True}), 200 + + +@admin_bp.route("/api/stations//recipes", methods=["GET"]) +@login_required +@admin_required +def api_list_station_recipes(station_id: int): + """Proxy: List recipes assigned to a station.""" + resp = api_client.get(f"/api/stations/{station_id}/recipes") + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 200 + + +@admin_bp.route("/api/stations//recipes", methods=["POST"]) +@login_required +@admin_required +def api_assign_recipe(station_id: int): + """Proxy: Assign a recipe to a station.""" + data = request.get_json(silent=True) or {} + resp = api_client.post(f"/api/stations/{station_id}/recipes", data=data) + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 201 + + +@admin_bp.route("/api/stations//recipes/", methods=["DELETE"]) +@login_required +@admin_required +def api_unassign_recipe(station_id: int, recipe_id: int): + """Proxy: Remove a recipe assignment from a station.""" + resp = api_client.delete(f"/api/stations/{station_id}/recipes/{recipe_id}") + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify({"deleted": True}), 200 diff --git a/client/templates/admin/stations.html b/client/templates/admin/stations.html new file mode 100644 index 0000000..2c45cbe --- /dev/null +++ b/client/templates/admin/stations.html @@ -0,0 +1,568 @@ +{% extends "base.html" %} + +{% block title %}{{ _('Gestione Stazioni') }} - TieMeasureFlow{% endblock %} + +{% block content %} + + +
+ + +
+

{{ _('Gestione Stazioni') }}

+

{{ _('Crea, modifica e gestisci stazioni di misurazione e relative ricette assegnate') }}

+
+ + +
+
+ + + + +
+ + +
+ + +
+
+ + + + + + + + + + + + + +
{{ _('Codice') }}{{ _('Nome') }}{{ _('Stato') }}{{ _('Azioni') }}
+
+ + +
+ +
+ {{ _('stazioni') }} +
+ + +
+
+
+ +
+

+ +
+ +
+
+ + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+
+
+ +
+
+

{{ _('Ricette Assegnate') }}

+

+
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+

+ {{ _('Ricette correntemente assegnate') }} () +

+
+ + +
+
+ + +
+ +
+ +
+
+
+ + +
+
+
+

{{ _('Conferma Eliminazione') }}

+

+ {{ _('Sei sicuro di voler eliminare la stazione') }} + ? +
{{ _('Verranno rimosse anche tutte le assegnazioni di ricette.') }} +

+
+ + +
+
+
+ +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/client/templates/components/navbar.html b/client/templates/components/navbar.html index 6ffacae..1712810 100644 --- a/client/templates/components/navbar.html +++ b/client/templates/components/navbar.html @@ -79,7 +79,7 @@ class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors duration-200 - {% if request.endpoint and request.endpoint.startswith('admin.') %} + {% if request.endpoint == 'admin.user_list' %} text-primary bg-primary-50 dark:bg-primary-900/20 {% endif %}"> @@ -88,6 +88,20 @@ {{ _('Utenti') }} + + + + + + + {{ _('Stazioni') }} + {% endif %} @@ -264,7 +278,7 @@ {% endif %} - {# Admin: Utenti #} + {# Admin: Utenti + Stazioni #} {% if current_user.get('is_admin') %} + + + + {{ _('Stazioni') }} + {% endif %} diff --git a/client/tests/test_admin_stations.py b/client/tests/test_admin_stations.py new file mode 100644 index 0000000..e3d93fa --- /dev/null +++ b/client/tests/test_admin_stations.py @@ -0,0 +1,134 @@ +"""Tests for the admin Stations management UI and proxy endpoints.""" +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_admin_api(): + """Patch api_client used by the admin blueprint.""" + mock = MagicMock() + with patch("blueprints.admin.api_client", mock): + yield mock + + +def test_station_list_page_requires_admin(logged_in_client, mock_admin_api): + mock_admin_api.get.return_value = [] + resp = logged_in_client.get("/admin/stations") + assert resp.status_code == 200 + assert b"Gestione Stazioni" in resp.data or b"stations" in resp.data.lower() + + +def test_station_list_page_calls_correct_endpoints(logged_in_client, mock_admin_api): + mock_admin_api.get.side_effect = [ + [{"id": 1, "code": "ST-001", "name": "Linea A", "location": None, + "notes": None, "active": True, "created_by": 1, "created_at": "2026-04-25T10:00:00"}], + [{"id": 1, "code": "REC-001", "name": "Test Recipe", "active": True}], + ] + resp = logged_in_client.get("/admin/stations") + assert resp.status_code == 200 + calls = [c.args[0] for c in mock_admin_api.get.call_args_list] + assert "/api/stations" in calls + assert "/api/recipes" in calls + + +def test_create_station_proxy(logged_in_client, mock_admin_api): + mock_admin_api.post.return_value = { + "id": 5, "code": "ST-005", "name": "Nuova", + "location": None, "notes": None, "active": True, + "created_by": 1, "created_at": "2026-04-25T10:00:00", + } + resp = logged_in_client.post( + "/admin/api/stations", + json={"code": "ST-005", "name": "Nuova", "active": True}, + ) + assert resp.status_code == 201 + body = resp.get_json() + assert body["code"] == "ST-005" + mock_admin_api.post.assert_called_once_with( + "/api/stations", + data={"code": "ST-005", "name": "Nuova", "active": True}, + ) + + +def test_create_station_propagates_error(logged_in_client, mock_admin_api): + mock_admin_api.post.return_value = { + "error": True, "status_code": 409, "detail": "code already exists", + } + resp = logged_in_client.post( + "/admin/api/stations", json={"code": "ST-001", "name": "x"}, + ) + assert resp.status_code == 409 + assert resp.get_json()["detail"] == "code already exists" + + +def test_update_station_proxy(logged_in_client, mock_admin_api): + mock_admin_api.put.return_value = { + "id": 3, "code": "ST-003", "name": "Aggiornata", + "location": "Reparto B", "notes": None, "active": False, + "created_by": 1, "created_at": "2026-04-25T10:00:00", + } + resp = logged_in_client.put( + "/admin/api/stations/3", + json={"name": "Aggiornata", "location": "Reparto B", "active": False}, + ) + assert resp.status_code == 200 + mock_admin_api.put.assert_called_once_with( + "/api/stations/3", + data={"name": "Aggiornata", "location": "Reparto B", "active": False}, + ) + + +def test_delete_station_proxy(logged_in_client, mock_admin_api): + mock_admin_api.delete.return_value = {} + resp = logged_in_client.delete("/admin/api/stations/7") + assert resp.status_code == 200 + assert resp.get_json() == {"deleted": True} + mock_admin_api.delete.assert_called_once_with("/api/stations/7") + + +def test_assign_recipe_proxy(logged_in_client, mock_admin_api): + mock_admin_api.post.return_value = { + "id": 1, "station_id": 2, "recipe_id": 10, + "assigned_by": 1, "assigned_at": "2026-04-25T10:00:00", + } + resp = logged_in_client.post( + "/admin/api/stations/2/recipes", json={"recipe_id": 10}, + ) + assert resp.status_code == 201 + mock_admin_api.post.assert_called_once_with( + "/api/stations/2/recipes", data={"recipe_id": 10}, + ) + + +def test_unassign_recipe_proxy(logged_in_client, mock_admin_api): + mock_admin_api.delete.return_value = {} + resp = logged_in_client.delete("/admin/api/stations/2/recipes/10") + assert resp.status_code == 200 + mock_admin_api.delete.assert_called_once_with("/api/stations/2/recipes/10") + + +def test_list_station_recipes_proxy(logged_in_client, mock_admin_api): + mock_admin_api.get.return_value = [ + {"id": 1, "code": "REC-001", "name": "R1", "active": True}, + ] + resp = logged_in_client.get("/admin/api/stations/2/recipes") + assert resp.status_code == 200 + assert resp.get_json() == [{"id": 1, "code": "REC-001", "name": "R1", "active": True}] + mock_admin_api.get.assert_called_once_with("/api/stations/2/recipes") + + +def test_non_admin_cannot_access(client, mock_admin_api): + """Non-admin user gets redirected away from station management.""" + with client.session_transaction() as sess: + sess["api_key"] = "test-key" + sess["user"] = { + "id": 2, + "username": "operator", + "roles": ["MeasurementTec"], + "is_admin": False, + "active": True, + } + sess["user_id"] = 2 + resp = client.get("/admin/stations", follow_redirects=False) + assert resp.status_code in (301, 302)