diff --git a/.env.example b/.env.example index e4a89fa..eb2ded8 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,10 @@ CLIENT_HOST=0.0.0.0 CLIENT_PORT=5000 CLIENT_SECRET_KEY=change-this-to-another-random-secret-key API_SERVER_URL=http://localhost:8000 +# Station code this client container belongs to (e.g. ST-001). +# Each physical tablet/PC deployment must set this unique per-station value. +# Leave empty only for a single-station all-in-one demo using ST-DEFAULT. +STATION_CODE=ST-DEFAULT # --- File Storage --- UPLOAD_DIR=server/uploads diff --git a/.gitignore b/.gitignore index eb3863b..b9461f3 100644 --- a/.gitignore +++ b/.gitignore @@ -52,9 +52,7 @@ node_modules/ # Flask-Babel compiled *.mo -# Alembic -server/migrations/versions/*.py -!server/migrations/versions/.gitkeep +# Alembic migrations are versioned in git (only __pycache__ is ignored, covered globally) # Logs *.log 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/blueprints/measure.py b/client/blueprints/measure.py index b0eae1d..e7466c2 100644 --- a/client/blueprints/measure.py +++ b/client/blueprints/measure.py @@ -22,8 +22,18 @@ measure_bp = Blueprint("measure", __name__) @role_required("MeasurementTec") def select_recipe(): """Recipe selection page with search and barcode support.""" - # Load recipes from API - resp = api_client.get("/api/recipes", params={"per_page": 100}) + # Fail-fast if STATION_CODE is not configured + if not Config.STATION_CODE: + return render_template("errors/station_not_configured.html"), 503 + + # Load recipes filtered by station + try: + resp = api_client.get_station_recipes(Config.STATION_CODE) + except Exception as e: + return render_template( + "errors/station_not_configured.html", error=str(e), + ), 502 + if isinstance(resp, dict) and resp.get("error"): flash( _("Errore nel caricamento delle ricette: %(detail)s", @@ -43,6 +53,7 @@ def select_recipe(): return render_template( "measure/select_recipe.html", recipes=recipes, + station_code=Config.STATION_CODE, auto_recipe_code=auto_recipe_code, auto_lot=auto_lot, auto_serial=auto_serial, diff --git a/client/config.py b/client/config.py index 51bd2f2..3a3ee54 100644 --- a/client/config.py +++ b/client/config.py @@ -22,6 +22,10 @@ class Config: PERMANENT_SESSION_LIFETIME = 28800 # 8 hours WTF_CSRF_TIME_LIMIT = 3600 # 1 hour + # Station identity: each deployed client container sets this to the station + # code it belongs to. Empty/None means "not configured". + STATION_CODE: str | None = os.getenv("STATION_CODE") or None + # Babel i18n BABEL_DEFAULT_LOCALE = "it" BABEL_DEFAULT_TIMEZONE = "Europe/Rome" diff --git a/client/services/api_client.py b/client/services/api_client.py index de6e0ac..4d3604a 100644 --- a/client/services/api_client.py +++ b/client/services/api_client.py @@ -131,5 +131,11 @@ class APIClient: "detail": f"Errore di connessione al server: {str(e)}" } + # --- Domain helpers --- + + def get_station_recipes(self, station_code: str) -> dict[str, Any]: + """Return the list of active recipes assigned to the given station.""" + return self.get(f"/api/stations/by-code/{station_code}/recipes") + api_client = APIClient() 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/templates/errors/station_not_configured.html b/client/templates/errors/station_not_configured.html new file mode 100644 index 0000000..d19e6d8 --- /dev/null +++ b/client/templates/errors/station_not_configured.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}{{ _('Stazione non configurata') }} — TieMeasureFlow{% endblock %} + +{% block content %} +
+
+
+ + + +
+ +

+ {{ _('Stazione non configurata') }} +

+ +

+ {{ _('Questo client non ha impostato la variabile di ambiente STATION_CODE.') }} +

+ +

+ {{ _('Contattare il responsabile IT: il file .env del container deve contenere STATION_CODE con il codice della stazione assegnata.') }} +

+ + {% if error %} +
{{ error }}
+ {% endif %} +
+
+{% endblock %} diff --git a/client/templates/measure/select_recipe.html b/client/templates/measure/select_recipe.html index 2d3aaf0..22f601a 100644 --- a/client/templates/measure/select_recipe.html +++ b/client/templates/measure/select_recipe.html @@ -75,6 +75,9 @@

{{ _('Scegli la ricetta di misura da eseguire') }}

+

+ {{ _('Stazione') }}: {{ station_code }} +

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) diff --git a/client/tests/test_api_client_stations.py b/client/tests/test_api_client_stations.py new file mode 100644 index 0000000..1d9ae07 --- /dev/null +++ b/client/tests/test_api_client_stations.py @@ -0,0 +1,15 @@ +"""Tests for the station-related helpers in api_client.""" +from unittest.mock import patch + +from services.api_client import APIClient + + +def test_get_station_recipes_calls_correct_endpoint(): + client = APIClient() + with patch.object(client, "get") as mock_get: + mock_get.return_value = [{"id": 1, "code": "R1", "name": "R1", "active": True}] + result = client.get_station_recipes("ST-001") + # The helper must call the underlying .get() with the exact endpoint path. + called_args, called_kwargs = mock_get.call_args + assert called_args[0] == "/api/stations/by-code/ST-001/recipes" + assert result == [{"id": 1, "code": "R1", "name": "R1", "active": True}] diff --git a/client/tests/test_config_station.py b/client/tests/test_config_station.py new file mode 100644 index 0000000..96bb825 --- /dev/null +++ b/client/tests/test_config_station.py @@ -0,0 +1,17 @@ +"""Tests that STATION_CODE is loaded from env and exposed on the client Config class.""" +import importlib +import os + + +def test_station_code_read_from_env(monkeypatch): + monkeypatch.setenv("STATION_CODE", "ST-TEST") + import config + importlib.reload(config) + assert config.Config.STATION_CODE == "ST-TEST" + + +def test_station_code_defaults_to_none_when_missing(monkeypatch): + monkeypatch.delenv("STATION_CODE", raising=False) + import config + importlib.reload(config) + assert config.Config.STATION_CODE is None diff --git a/client/tests/test_measure.py b/client/tests/test_measure.py index 3309737..9992c4b 100644 --- a/client/tests/test_measure.py +++ b/client/tests/test_measure.py @@ -8,15 +8,18 @@ import pytest class TestSelectRecipe: """GET /measure/select tests.""" - def test_select_recipe_renders(self, logged_in_client, mock_api_client): + def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch): """Recipe selection page renders for MeasurementTec role.""" - mock_api_client.get.return_value = { - "items": [ - {"id": 1, "code": "REC-001", "name": "Test Recipe"}, - ], - "total": 1, - "pages": 1, - } + monkeypatch.setenv("STATION_CODE", "ST-TEST") + import config + import importlib + importlib.reload(config) + import blueprints.measure + importlib.reload(blueprints.measure) + + mock_api_client.get_station_recipes.return_value = [ + {"id": 1, "code": "REC-001", "name": "Test Recipe"}, + ] resp = logged_in_client.get("/measure/select") assert resp.status_code == 200 diff --git a/client/tests/test_measure_station_filter.py b/client/tests/test_measure_station_filter.py new file mode 100644 index 0000000..59efe10 --- /dev/null +++ b/client/tests/test_measure_station_filter.py @@ -0,0 +1,37 @@ +"""Verify that /measure/select reads STATION_CODE and filters recipes via the server.""" +import importlib +from unittest.mock import patch, MagicMock + + +def _reload_measure(monkeypatch, station_code=None): + """Reload config and measure module under the given STATION_CODE env.""" + if station_code is None: + monkeypatch.delenv("STATION_CODE", raising=False) + else: + monkeypatch.setenv("STATION_CODE", station_code) + import config + importlib.reload(config) + import blueprints.measure + importlib.reload(blueprints.measure) + + +def test_select_recipe_calls_station_endpoint(logged_in_client, monkeypatch): + _reload_measure(monkeypatch, station_code="ST-TEST") + from blueprints import measure as measure_bp_mod + with patch.object(measure_bp_mod, "api_client") as mock_api: + mock_api.get_station_recipes.return_value = [ + {"id": 1, "code": "R1", "name": "Recipe 1", "active": True}, + ] + resp = logged_in_client.get("/measure/select") + assert resp.status_code == 200 + mock_api.get_station_recipes.assert_called_once() + args, kwargs = mock_api.get_station_recipes.call_args + assert args[0] == "ST-TEST" or kwargs.get("station_code") == "ST-TEST" + + +def test_select_recipe_without_station_code_shows_error(logged_in_client, monkeypatch): + _reload_measure(monkeypatch, station_code=None) + resp = logged_in_client.get("/measure/select") + assert resp.status_code == 503 + body = resp.data.lower() + assert b"station_code" in body or b"stazione" in body diff --git a/server/main.py b/server/main.py index 9489a9d..2028b51 100644 --- a/server/main.py +++ b/server/main.py @@ -20,6 +20,7 @@ from routers.settings import router as settings_router from routers.reports import router as reports_router from routers.statistics import router as statistics_router from routers.setup import router as setup_router +from routers.stations import router as stations_router @asynccontextmanager @@ -71,6 +72,7 @@ app.include_router(settings_router) app.include_router(statistics_router) app.include_router(reports_router) app.include_router(setup_router) +app.include_router(stations_router) @app.get("/api/health") diff --git a/server/migrations/versions/001_add_image_path_to_recipe_and_subtask.py b/server/migrations/versions/001_add_image_path_to_recipe_and_subtask.py new file mode 100644 index 0000000..c1a5d39 --- /dev/null +++ b/server/migrations/versions/001_add_image_path_to_recipe_and_subtask.py @@ -0,0 +1,27 @@ +"""add image_path to recipe and subtask + +Revision ID: 001_image_path +Revises: +Create Date: 2026-02-20 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '001_image_path' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('recipes', sa.Column('image_path', sa.String(500), nullable=True)) + op.add_column('recipe_subtasks', sa.Column('image_path', sa.String(500), nullable=True)) + + +def downgrade() -> None: + op.drop_column('recipe_subtasks', 'image_path') + op.drop_column('recipes', 'image_path') diff --git a/server/migrations/versions/002_add_stations.py b/server/migrations/versions/002_add_stations.py new file mode 100644 index 0000000..e3d6edd --- /dev/null +++ b/server/migrations/versions/002_add_stations.py @@ -0,0 +1,53 @@ +"""add stations and station_recipe_assignments tables + +Revision ID: 002_add_stations +Revises: 001_image_path +Create Date: 2026-04-17 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '002_add_stations' +down_revision: Union[str, None] = '001_image_path' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'stations', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('code', sa.String(100), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('location', sa.String(255), nullable=True), + sa.Column('notes', sa.Text, nullable=True), + sa.Column('active', sa.Boolean, nullable=False, server_default='1'), + sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint('code', name='uq_stations_code'), + sa.Index('ix_stations_active', 'active'), + mysql_engine='InnoDB', + mysql_charset='utf8mb4', + ) + + op.create_table( + 'station_recipe_assignments', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('station_id', sa.Integer, sa.ForeignKey('stations.id', ondelete='CASCADE'), nullable=False), + sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id', ondelete='CASCADE'), nullable=False), + sa.Column('assigned_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False), + sa.Column('assigned_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint('station_id', 'recipe_id', name='uq_station_recipe'), + sa.Index('ix_sra_station', 'station_id'), + sa.Index('ix_sra_recipe', 'recipe_id'), + mysql_engine='InnoDB', + mysql_charset='utf8mb4', + ) + + +def downgrade() -> None: + op.drop_table('station_recipe_assignments') + op.drop_table('stations') diff --git a/server/models/station.py b/server/models/station.py new file mode 100644 index 0000000..b8b6a33 --- /dev/null +++ b/server/models/station.py @@ -0,0 +1,70 @@ +"""Station and StationRecipeAssignment models. + +A Station represents a physical control point (typically one per tablet/PC). +Recipes are assigned to stations so that each station only sees the products +it is supposed to inspect. +""" +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from database import Base + +if TYPE_CHECKING: + from models.recipe import Recipe + + +class Station(Base): + __tablename__ = "stations" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + + assignments: Mapped[list["StationRecipeAssignment"]] = relationship( + back_populates="station", cascade="all, delete-orphan", lazy="selectin" + ) + + __table_args__ = ( + UniqueConstraint("code", name="uq_stations_code"), + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, + ) + + def __repr__(self) -> str: + return f"" + + +class StationRecipeAssignment(Base): + __tablename__ = "station_recipe_assignments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + station_id: Mapped[int] = mapped_column( + Integer, ForeignKey("stations.id", ondelete="CASCADE"), nullable=False, index=True + ) + recipe_id: Mapped[int] = mapped_column( + Integer, ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True + ) + assigned_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + assigned_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + + station: Mapped["Station"] = relationship(back_populates="assignments") + recipe: Mapped["Recipe"] = relationship(lazy="selectin") + + __table_args__ = ( + UniqueConstraint("station_id", "recipe_id", name="uq_station_recipe"), + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, + ) + + def __repr__(self) -> str: + return f"" diff --git a/server/routers/setup.py b/server/routers/setup.py index 7d9e403..5ade483 100644 --- a/server/routers/setup.py +++ b/server/routers/setup.py @@ -13,12 +13,14 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel from sqlalchemy import inspect as sa_inspect, select +from sqlalchemy.ext.asyncio import AsyncSession from config import settings from database import Base, engine, async_session_factory from models import ( User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, ) +from models.station import Station, StationRecipeAssignment from services.auth_service import hash_password from services.measurement_service import calculate_pass_fail @@ -161,6 +163,50 @@ def _generate_measurements_for_subtask( return measurements +async def _seed_default_station(session: AsyncSession, admin_user: User) -> None: + """Ensure ST-DEFAULT station exists and all active recipes are assigned to it. + + Idempotent: safe to call multiple times. Skips creation if the station + already exists and only adds assignments for recipes not yet assigned. + """ + existing = await session.execute( + select(Station).where(Station.code == "ST-DEFAULT") + ) + default_station = existing.scalar_one_or_none() + + if default_station is None: + default_station = Station( + code="ST-DEFAULT", + name="Default Station", + location="Initial seed - change me", + created_by=admin_user.id, + ) + session.add(default_station) + await session.flush() + await session.refresh(default_station) + + # Collect already-assigned recipe IDs to avoid unique-constraint violations. + existing_assignments = await session.execute( + select(StationRecipeAssignment.recipe_id).where( + StationRecipeAssignment.station_id == default_station.id + ) + ) + existing_ids = {row[0] for row in existing_assignments} + + recipes_result = await session.execute( + select(Recipe).where(Recipe.active == True) + ) + for r in recipes_result.scalars().all(): + if r.id not in existing_ids: + session.add(StationRecipeAssignment( + station_id=default_station.id, + recipe_id=r.id, + assigned_by=admin_user.id, + )) + + await session.flush() + + # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @@ -299,6 +345,7 @@ async def seed_demo_data(body: PasswordBody): select(Recipe).where(Recipe.code == "DEMO-001") ) if existing_recipe.scalar_one_or_none(): + await _seed_default_station(session, admin_user) return { "status": "ok", "message": "Demo data already exists, skipped", @@ -453,6 +500,9 @@ async def seed_demo_data(body: PasswordBody): session.add_all(ms) measurements_created += len(ms) + # ---- Default Station ---------------------------------------------- + await _seed_default_station(session, admin_user) + return { "status": "ok", "message": "Demo data seeded successfully", diff --git a/server/routers/stations.py b/server/routers/stations.py new file mode 100644 index 0000000..f44da27 --- /dev/null +++ b/server/routers/stations.py @@ -0,0 +1,142 @@ +"""Stations router - CRUD + recipe assignments.""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db +from middleware.api_key import get_current_user, require_admin_user +from models.user import User +from schemas.station import ( + StationCreate, + StationUpdate, + StationResponse, + StationRecipeAssignmentCreate, + StationRecipeAssignmentResponse, + RecipeSummary, +) +from services import station_service + +router = APIRouter(prefix="/api/stations", tags=["stations"]) + + +@router.get("", response_model=list[StationResponse]) +async def list_stations( + active_only: bool = False, + admin: User = Depends(require_admin_user), + db: AsyncSession = Depends(get_db), +): + """List all stations (admin only).""" + stations = await station_service.list_stations(db, active_only=active_only) + return [StationResponse.model_validate(s) for s in stations] + + +@router.post("", response_model=StationResponse, status_code=status.HTTP_201_CREATED) +async def create_new_station( + data: StationCreate, + admin: User = Depends(require_admin_user), + db: AsyncSession = Depends(get_db), +): + """Create a station (admin only).""" + station = await station_service.create_station(db, data, admin) + return StationResponse.model_validate(station) + + +# NOTE: this literal-prefix route must stay above the /{station_id} routes. +# The int-typed station_id param already guards against "by-code" being +# matched as a station id, but keeping the explicit order avoids surprises +# during refactors (e.g. if someone regroups handlers by HTTP method). +@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary]) +async def list_recipes_by_station_code( + code: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Operator view: active recipes assigned to the station with this code. + + Used by the Flask client at startup / on select_recipe page. + Any authenticated user can call this; filtering is by station code from + the client's STATION_CODE environment variable. + """ + station = await station_service.get_station_by_code(db, code) + if station is None or not station.active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Station not found", + ) + recipes = await station_service.list_station_recipes(db, station.id) + return [RecipeSummary.model_validate(r) for r in recipes] + + +@router.get("/{station_id}", response_model=StationResponse) +async def get_single_station( + station_id: int, + admin: User = Depends(require_admin_user), + db: AsyncSession = Depends(get_db), +): + """Get a station by id (admin only).""" + station = await station_service.get_station(db, station_id) + return StationResponse.model_validate(station) + + +@router.put("/{station_id}", response_model=StationResponse) +async def update_existing_station( + station_id: int, + data: StationUpdate, + admin: User = Depends(require_admin_user), + db: AsyncSession = Depends(get_db), +): + """Update a station (admin only).""" + station = await station_service.update_station(db, station_id, data) + return StationResponse.model_validate(station) + + +@router.delete("/{station_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_station( + station_id: int, + admin: User = Depends(require_admin_user), + db: AsyncSession = Depends(get_db), +): + """Delete a station (admin only). Cascades to assignments.""" + await station_service.delete_station(db, station_id) + + +@router.get("/{station_id}/recipes", response_model=list[RecipeSummary]) +async def list_assigned_recipes( + station_id: int, + admin: User = Depends(require_admin_user), + db: AsyncSession = Depends(get_db), +): + """Admin view: recipes assigned to this station (active only).""" + recipes = await station_service.list_station_recipes(db, station_id) + return [RecipeSummary.model_validate(r) for r in recipes] + + +@router.post( + "/{station_id}/recipes", + response_model=StationRecipeAssignmentResponse, + status_code=status.HTTP_201_CREATED, +) +async def assign_recipe_to_station( + station_id: int, + data: StationRecipeAssignmentCreate, + admin: User = Depends(require_admin_user), + db: AsyncSession = Depends(get_db), +): + """Assign a recipe to a station (admin only).""" + assignment = await station_service.assign_recipe( + db, station_id, data.recipe_id, admin, + ) + return StationRecipeAssignmentResponse.model_validate(assignment) + + +@router.delete( + "/{station_id}/recipes/{recipe_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def unassign_recipe_from_station( + station_id: int, + recipe_id: int, + admin: User = Depends(require_admin_user), + db: AsyncSession = Depends(get_db), +): + """Remove a recipe assignment (admin only).""" + await station_service.unassign_recipe(db, station_id, recipe_id) diff --git a/server/schemas/station.py b/server/schemas/station.py new file mode 100644 index 0000000..7b0c6b0 --- /dev/null +++ b/server/schemas/station.py @@ -0,0 +1,57 @@ +"""Pydantic schemas for Station and StationRecipeAssignment.""" +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class StationCreate(BaseModel): + code: str = Field(..., min_length=1, max_length=100) + name: str = Field(..., min_length=1, max_length=255) + location: Optional[str] = Field(default=None, max_length=255) + notes: Optional[str] = None + active: bool = True + + +class StationUpdate(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + location: Optional[str] = Field(default=None, max_length=255) + notes: Optional[str] = None + active: Optional[bool] = None + + +class StationResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + code: str + name: str + location: Optional[str] + notes: Optional[str] + active: bool + created_by: int + created_at: datetime + + +class StationRecipeAssignmentCreate(BaseModel): + recipe_id: int = Field(..., gt=0) + + +class StationRecipeAssignmentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + station_id: int + recipe_id: int + assigned_by: int + assigned_at: datetime + + +class RecipeSummary(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + code: str + name: str + active: bool + + +class StationWithRecipesResponse(StationResponse): + recipes: list[RecipeSummary] = Field(default_factory=list) diff --git a/server/services/station_service.py b/server/services/station_service.py new file mode 100644 index 0000000..e203247 --- /dev/null +++ b/server/services/station_service.py @@ -0,0 +1,155 @@ +"""Business logic for stations and recipe assignments. + +Routers must call into these functions rather than manipulating models directly. +All functions are async and accept an AsyncSession; they flush but do NOT commit +(commit is handled by the FastAPI get_db dependency). +""" +from typing import Optional + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from models.recipe import Recipe +from models.station import Station, StationRecipeAssignment +from models.user import User +from schemas.station import StationCreate, StationUpdate + + +async def create_station( + db: AsyncSession, data: StationCreate, creator: User, +) -> Station: + existing = await db.execute(select(Station).where(Station.code == data.code)) + if existing.scalar_one_or_none() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Station code '{data.code}' already exists", + ) + station = Station( + code=data.code, + name=data.name, + location=data.location, + notes=data.notes, + active=data.active, + created_by=creator.id, + ) + db.add(station) + await db.flush() + await db.refresh(station) + return station + + +async def get_station(db: AsyncSession, station_id: int) -> Station: + result = await db.execute(select(Station).where(Station.id == station_id)) + station = result.scalar_one_or_none() + if station is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Station not found", + ) + return station + + +async def get_station_by_code(db: AsyncSession, code: str) -> Optional[Station]: + result = await db.execute(select(Station).where(Station.code == code)) + return result.scalar_one_or_none() + + +async def list_stations(db: AsyncSession, active_only: bool = False) -> list[Station]: + query = select(Station).order_by(Station.code) + if active_only: + query = query.where(Station.active == True) # noqa: E712 + result = await db.execute(query) + return list(result.scalars().all()) + + +async def update_station( + db: AsyncSession, station_id: int, data: StationUpdate, +) -> Station: + station = await get_station(db, station_id) + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(station, field, value) + await db.flush() + await db.refresh(station) + return station + + +async def delete_station(db: AsyncSession, station_id: int) -> None: + station = await get_station(db, station_id) + # Explicitly delete assignments so the ORM cascade fires within the + # current session (SQLite test DB does not enforce FK CASCADE without + # PRAGMA foreign_keys = ON; production MySQL handles it at DB level too, + # but explicit ORM deletion is engine-agnostic and safer). + assignments = await db.execute( + select(StationRecipeAssignment).where( + StationRecipeAssignment.station_id == station_id + ) + ) + for assignment in assignments.scalars().all(): + await db.delete(assignment) + await db.delete(station) + await db.flush() + + +async def assign_recipe( + db: AsyncSession, station_id: int, recipe_id: int, assigner: User, +) -> StationRecipeAssignment: + await get_station(db, station_id) + recipe_row = await db.execute(select(Recipe).where(Recipe.id == recipe_id)) + if recipe_row.scalar_one_or_none() is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found", + ) + existing = await db.execute( + select(StationRecipeAssignment).where( + StationRecipeAssignment.station_id == station_id, + StationRecipeAssignment.recipe_id == recipe_id, + ) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Recipe already assigned to this station", + ) + assignment = StationRecipeAssignment( + station_id=station_id, recipe_id=recipe_id, assigned_by=assigner.id, + ) + db.add(assignment) + await db.flush() + await db.refresh(assignment) + return assignment + + +async def unassign_recipe( + db: AsyncSession, station_id: int, recipe_id: int, +) -> None: + result = await db.execute( + select(StationRecipeAssignment).where( + StationRecipeAssignment.station_id == station_id, + StationRecipeAssignment.recipe_id == recipe_id, + ) + ) + assignment = result.scalar_one_or_none() + if assignment is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assignment not found", + ) + await db.delete(assignment) + await db.flush() + + +async def list_station_recipes( + db: AsyncSession, station_id: int, +) -> list[Recipe]: + """Return active recipes assigned to this station, ordered by code.""" + await get_station(db, station_id) + result = await db.execute( + select(Recipe) + .join(StationRecipeAssignment, StationRecipeAssignment.recipe_id == Recipe.id) + .where( + StationRecipeAssignment.station_id == station_id, + Recipe.active == True, # noqa: E712 + ) + .order_by(Recipe.code) + ) + return list(result.scalars().all()) diff --git a/server/tests/conftest.py b/server/tests/conftest.py index b424c9f..abe4d79 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -39,6 +39,7 @@ from models.task import RecipeTask, RecipeSubtask from models.measurement import Measurement from models.access_log import AccessLog from models.setting import SystemSetting, RecipeVersionAudit +from models.station import Station, StationRecipeAssignment from services.auth_service import hash_password, generate_api_key # --------------------------------------------------------------------------- diff --git a/server/tests/test_station_model.py b/server/tests/test_station_model.py new file mode 100644 index 0000000..5935bac --- /dev/null +++ b/server/tests/test_station_model.py @@ -0,0 +1,73 @@ +"""Test the Station and StationRecipeAssignment ORM models.""" +import pytest +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from models.station import Station, StationRecipeAssignment +from tests.conftest import _create_user, create_test_recipe + + +async def test_create_station(db_session: AsyncSession): + admin = await _create_user(db_session, username="admin1", is_admin=True) + station = Station( + code="ST-001", + name="Linea 1", + location="Reparto Nord", + created_by=admin.id, + ) + db_session.add(station) + await db_session.flush() + await db_session.refresh(station) + assert station.id is not None + assert station.active is True + assert station.created_at is not None + + +async def test_station_code_is_unique(db_session: AsyncSession): + admin = await _create_user(db_session, username="admin2", is_admin=True) + db_session.add(Station(code="ST-DUP", name="A", created_by=admin.id)) + await db_session.flush() + db_session.add(Station(code="ST-DUP", name="B", created_by=admin.id)) + with pytest.raises(IntegrityError): + await db_session.flush() + await db_session.rollback() + + +async def test_assign_recipe_to_station(db_session: AsyncSession): + admin = await _create_user(db_session, username="admin3", is_admin=True) + station = Station(code="ST-002", name="Linea 2", created_by=admin.id) + db_session.add(station) + await db_session.flush() + recipe = await create_test_recipe(db_session, user_id=admin.id, code="REC-X") + assignment = StationRecipeAssignment( + station_id=station.id, recipe_id=recipe.id, assigned_by=admin.id, + ) + db_session.add(assignment) + await db_session.flush() + result = await db_session.execute( + select(StationRecipeAssignment).where( + StationRecipeAssignment.station_id == station.id + ) + ) + assignments = result.scalars().all() + assert len(assignments) == 1 + assert assignments[0].recipe_id == recipe.id + + +async def test_duplicate_assignment_is_rejected(db_session: AsyncSession): + admin = await _create_user(db_session, username="admin4", is_admin=True) + station = Station(code="ST-003", name="Linea 3", created_by=admin.id) + db_session.add(station) + await db_session.flush() + recipe = await create_test_recipe(db_session, user_id=admin.id, code="REC-Y") + db_session.add(StationRecipeAssignment( + station_id=station.id, recipe_id=recipe.id, assigned_by=admin.id, + )) + await db_session.flush() + db_session.add(StationRecipeAssignment( + station_id=station.id, recipe_id=recipe.id, assigned_by=admin.id, + )) + with pytest.raises(IntegrityError): + await db_session.flush() + await db_session.rollback() diff --git a/server/tests/test_station_schemas.py b/server/tests/test_station_schemas.py new file mode 100644 index 0000000..5b75763 --- /dev/null +++ b/server/tests/test_station_schemas.py @@ -0,0 +1,35 @@ +"""Tests for Station Pydantic schemas.""" +import pytest +from pydantic import ValidationError + +from schemas.station import ( + StationCreate, StationUpdate, StationResponse, + StationRecipeAssignmentCreate, StationRecipeAssignmentResponse, + StationWithRecipesResponse, +) + + +def test_station_create_valid(): + data = StationCreate(code="ST-001", name="Linea 1", location="Reparto Nord") + assert data.code == "ST-001" + assert data.active is True + + +def test_station_create_rejects_empty_code(): + with pytest.raises(ValidationError): + StationCreate(code="", name="X") + + +def test_station_create_rejects_too_long_code(): + with pytest.raises(ValidationError): + StationCreate(code="A" * 101, name="X") + + +def test_station_update_all_optional(): + data = StationUpdate() + assert data.name is None + + +def test_station_assignment_create(): + data = StationRecipeAssignmentCreate(recipe_id=42) + assert data.recipe_id == 42 diff --git a/server/tests/test_station_seed.py b/server/tests/test_station_seed.py new file mode 100644 index 0000000..7d8b1ac --- /dev/null +++ b/server/tests/test_station_seed.py @@ -0,0 +1,100 @@ +"""Verify /api/setup/seed creates a default station with all recipes assigned. + +DEVIATION FROM PLAN: The real setup endpoint is NOT a single /api/setup/initialize. +The setup is split into separate endpoints: + - POST /api/setup/init-db (creates tables) + - POST /api/setup/seed (seeds users + demo recipe + stations) + +The seed body is {"password": "..."} with no load_demo_data flag (demo data +is always seeded). The station seed is added inside /api/setup/seed. + +The seed endpoint uses async_session_factory directly (not get_db dependency +injection), so we must monkeypatch routers.setup.engine and +routers.setup.async_session_factory to point at the test SQLite engine, +exactly as done in test_setup.py. +""" +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import settings +from models.station import Station, StationRecipeAssignment +from models.recipe import Recipe +from tests.conftest import test_engine, TestSessionFactory + + +SETUP_PWD = "test-setup-pwd" + + +@pytest.fixture(autouse=True) +def enable_setup(monkeypatch): + """Enable setup endpoints and redirect engine/session to test SQLite DB.""" + monkeypatch.setattr(settings, "setup_password", SETUP_PWD) + import routers.setup as setup_mod + monkeypatch.setattr(setup_mod, "engine", test_engine) + monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory) + + +async def _seed(client: AsyncClient) -> dict: + """Init DB tables then run seed; return seed response JSON.""" + resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD}) + assert resp.status_code == 200, resp.text + resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD}) + assert resp.status_code == 200, resp.text + return resp.json() + + +@pytest.mark.asyncio +async def test_setup_seed_creates_default_station_and_assigns_recipes( + client: AsyncClient, + db_session: AsyncSession, +): + """After seeding, ST-DEFAULT station must exist and all active recipes assigned.""" + await _seed(client) + + # Default station must exist and be active. + result = await db_session.execute( + select(Station).where(Station.code == "ST-DEFAULT") + ) + default = result.scalar_one_or_none() + assert default is not None, "ST-DEFAULT station was not created" + assert default.active is True + + # All active recipes must be assigned to the default station. + active_recipes_result = await db_session.execute( + select(Recipe).where(Recipe.active == True) + ) + active_recipes = active_recipes_result.scalars().all() + assert len(active_recipes) > 0, "demo seed must create at least one active recipe" + + assignments_result = await db_session.execute( + select(StationRecipeAssignment).where( + StationRecipeAssignment.station_id == default.id + ) + ) + n_assignments = len(assignments_result.scalars().all()) + assert n_assignments == len(active_recipes), ( + f"Expected {len(active_recipes)} assignment(s), got {n_assignments}" + ) + + +@pytest.mark.asyncio +async def test_setup_seed_station_idempotent( + client: AsyncClient, + db_session: AsyncSession, +): + """Running seed twice must not duplicate ST-DEFAULT or its assignments.""" + # First run — creates everything. + await _seed(client) + + # Second run — recipe DEMO-001 already exists → seed returns early. + # ST-DEFAULT must still exist (not re-created, not duplicated). + resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD}) + assert resp.status_code == 200, resp.text + + stations_result = await db_session.execute( + select(Station).where(Station.code == "ST-DEFAULT") + ) + stations = stations_result.scalars().all() + assert len(stations) == 1, f"Expected exactly 1 ST-DEFAULT, got {len(stations)}" diff --git a/server/tests/test_station_service.py b/server/tests/test_station_service.py new file mode 100644 index 0000000..e232fe0 --- /dev/null +++ b/server/tests/test_station_service.py @@ -0,0 +1,114 @@ +"""Tests for station_service business logic.""" +import pytest +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from models.station import Station +from schemas.station import StationCreate, StationUpdate +from services.station_service import ( + create_station, update_station, delete_station, + assign_recipe, unassign_recipe, list_station_recipes, + get_station_by_code, +) +from tests.conftest import _create_user, create_test_recipe + + +async def test_create_station_ok(db_session: AsyncSession): + admin = await _create_user(db_session, username="a1", is_admin=True) + station = await create_station( + db_session, StationCreate(code="ST-100", name="Pilot"), admin + ) + assert station.id is not None + assert station.code == "ST-100" + + +async def test_create_station_duplicate_code(db_session: AsyncSession): + admin = await _create_user(db_session, username="a2", is_admin=True) + await create_station(db_session, StationCreate(code="ST-DUP", name="A"), admin) + with pytest.raises(HTTPException) as exc: + await create_station(db_session, StationCreate(code="ST-DUP", name="B"), admin) + assert exc.value.status_code == 409 + + +async def test_update_station(db_session: AsyncSession): + admin = await _create_user(db_session, username="a3", is_admin=True) + station = await create_station(db_session, StationCreate(code="ST-U", name="Old"), admin) + updated = await update_station( + db_session, station.id, StationUpdate(name="New name"), + ) + assert updated.name == "New name" + assert updated.code == "ST-U" + + +async def test_update_missing_station(db_session: AsyncSession): + with pytest.raises(HTTPException) as exc: + await update_station(db_session, 9999, StationUpdate(name="x")) + assert exc.value.status_code == 404 + + +async def test_assign_and_list_recipes(db_session: AsyncSession): + admin = await _create_user(db_session, username="a4", is_admin=True) + station = await create_station(db_session, StationCreate(code="ST-R", name="R"), admin) + r1 = await create_test_recipe(db_session, user_id=admin.id, code="REC-R1") + r2 = await create_test_recipe(db_session, user_id=admin.id, code="REC-R2") + await assign_recipe(db_session, station.id, r1.id, admin) + await assign_recipe(db_session, station.id, r2.id, admin) + recipes = await list_station_recipes(db_session, station.id) + assert {r.code for r in recipes} == {"REC-R1", "REC-R2"} + + +async def test_assign_same_recipe_twice_is_409(db_session: AsyncSession): + admin = await _create_user(db_session, username="a5", is_admin=True) + station = await create_station(db_session, StationCreate(code="ST-D", name="D"), admin) + r = await create_test_recipe(db_session, user_id=admin.id, code="REC-D") + await assign_recipe(db_session, station.id, r.id, admin) + with pytest.raises(HTTPException) as exc: + await assign_recipe(db_session, station.id, r.id, admin) + assert exc.value.status_code == 409 + + +async def test_unassign_recipe(db_session: AsyncSession): + admin = await _create_user(db_session, username="a6", is_admin=True) + station = await create_station(db_session, StationCreate(code="ST-UN", name="UN"), admin) + r = await create_test_recipe(db_session, user_id=admin.id, code="REC-UN") + await assign_recipe(db_session, station.id, r.id, admin) + await unassign_recipe(db_session, station.id, r.id) + recipes = await list_station_recipes(db_session, station.id) + assert recipes == [] + + +async def test_get_station_by_code(db_session: AsyncSession): + admin = await _create_user(db_session, username="a7", is_admin=True) + await create_station(db_session, StationCreate(code="ST-FIND", name="F"), admin) + found = await get_station_by_code(db_session, "ST-FIND") + assert found is not None + assert found.name == "F" + missing = await get_station_by_code(db_session, "ST-NOPE") + assert missing is None + + +async def test_list_recipes_only_returns_active(db_session: AsyncSession): + admin = await _create_user(db_session, username="a8", is_admin=True) + station = await create_station(db_session, StationCreate(code="ST-A", name="A"), admin) + active = await create_test_recipe(db_session, user_id=admin.id, code="REC-AC") + inactive = await create_test_recipe(db_session, user_id=admin.id, code="REC-IN") + inactive.active = False + await db_session.flush() + await assign_recipe(db_session, station.id, active.id, admin) + await assign_recipe(db_session, station.id, inactive.id, admin) + recipes = await list_station_recipes(db_session, station.id) + assert [r.code for r in recipes] == ["REC-AC"] + + +async def test_delete_station_cascades_assignments(db_session: AsyncSession): + from sqlalchemy import select + from models.station import StationRecipeAssignment + admin = await _create_user(db_session, username="a9", is_admin=True) + station = await create_station(db_session, StationCreate(code="ST-DEL", name="D"), admin) + r = await create_test_recipe(db_session, user_id=admin.id, code="REC-DEL") + await assign_recipe(db_session, station.id, r.id, admin) + await delete_station(db_session, station.id) + remaining = await db_session.execute( + select(StationRecipeAssignment).where(StationRecipeAssignment.station_id == station.id) + ) + assert remaining.scalars().all() == [] diff --git a/server/tests/test_stations_api.py b/server/tests/test_stations_api.py new file mode 100644 index 0000000..c936ee8 --- /dev/null +++ b/server/tests/test_stations_api.py @@ -0,0 +1,203 @@ +"""Integration tests for /api/stations endpoints.""" +import pytest +from httpx import AsyncClient + +from tests.conftest import auth_headers, create_test_recipe + + +async def test_list_stations_requires_auth(client: AsyncClient): + resp = await client.get("/api/stations") + assert resp.status_code == 401 + + +async def test_create_station_as_admin(client: AsyncClient, admin_user): + resp = await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-API", "name": "Via API"}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["code"] == "ST-API" + assert body["active"] is True + assert body["id"] > 0 + + +async def test_create_station_non_admin_is_403(client: AsyncClient, maker_user): + resp = await client.post( + "/api/stations", + headers=auth_headers(maker_user), + json={"code": "ST-NO", "name": "No"}, + ) + assert resp.status_code == 403 + + +async def test_update_station(client: AsyncClient, admin_user): + created = await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-UP", "name": "Old"}, + ) + station_id = created.json()["id"] + resp = await client.put( + f"/api/stations/{station_id}", + headers=auth_headers(admin_user), + json={"name": "New", "active": False}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "New" + assert body["active"] is False + + +async def test_delete_station(client: AsyncClient, admin_user): + created = await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-D", "name": "D"}, + ) + sid = created.json()["id"] + resp = await client.delete( + f"/api/stations/{sid}", headers=auth_headers(admin_user), + ) + assert resp.status_code == 204 + again = await client.get( + f"/api/stations/{sid}", headers=auth_headers(admin_user), + ) + assert again.status_code == 404 + + +async def test_assign_and_unassign_recipe( + client: AsyncClient, admin_user, db_session, +): + recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-AS") + await db_session.commit() + created = await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-ASSIGN", "name": "A"}, + ) + sid = created.json()["id"] + a = await client.post( + f"/api/stations/{sid}/recipes", + headers=auth_headers(admin_user), + json={"recipe_id": recipe.id}, + ) + assert a.status_code == 201 + r = await client.get( + f"/api/stations/{sid}/recipes", + headers=auth_headers(admin_user), + ) + assert r.status_code == 200 + assert [rec["code"] for rec in r.json()] == ["REC-AS"] + u = await client.delete( + f"/api/stations/{sid}/recipes/{recipe.id}", + headers=auth_headers(admin_user), + ) + assert u.status_code == 204 + r2 = await client.get( + f"/api/stations/{sid}/recipes", + headers=auth_headers(admin_user), + ) + assert r2.json() == [] + + +async def test_list_recipes_by_station_code( + client: AsyncClient, admin_user, measurement_tec_user, db_session, +): + recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-BC") + await db_session.commit() + created = await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-BC", "name": "BC"}, + ) + sid = created.json()["id"] + await client.post( + f"/api/stations/{sid}/recipes", + headers=auth_headers(admin_user), + json={"recipe_id": recipe.id}, + ) + resp = await client.get( + "/api/stations/by-code/ST-BC/recipes", + headers=auth_headers(measurement_tec_user), + ) + assert resp.status_code == 200 + assert [r["code"] for r in resp.json()] == ["REC-BC"] + + +async def test_list_recipes_by_unknown_code_404( + client: AsyncClient, measurement_tec_user, +): + resp = await client.get( + "/api/stations/by-code/ST-DOES-NOT-EXIST/recipes", + headers=auth_headers(measurement_tec_user), + ) + assert resp.status_code == 404 + + +async def test_admin_can_list_stations(client: AsyncClient, admin_user): + await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-L1", "name": "A"}, + ) + await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-L2", "name": "B", "active": False}, + ) + resp = await client.get("/api/stations", headers=auth_headers(admin_user)) + assert resp.status_code == 200 + codes = {s["code"] for s in resp.json()} + assert {"ST-L1", "ST-L2"}.issubset(codes) + + resp_active = await client.get( + "/api/stations?active_only=true", headers=auth_headers(admin_user), + ) + assert resp_active.status_code == 200 + active_codes = {s["code"] for s in resp_active.json()} + assert "ST-L1" in active_codes + assert "ST-L2" not in active_codes + + +async def test_assign_recipe_not_found_returns_404( + client: AsyncClient, admin_user, +): + created = await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-NR", "name": "NR"}, + ) + sid = created.json()["id"] + resp = await client.post( + f"/api/stations/{sid}/recipes", + headers=auth_headers(admin_user), + json={"recipe_id": 99999}, + ) + assert resp.status_code == 404 + + +async def test_duplicate_assignment_returns_409( + client: AsyncClient, admin_user, db_session, +): + recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-DUP") + await db_session.commit() + created = await client.post( + "/api/stations", + headers=auth_headers(admin_user), + json={"code": "ST-DUP-A", "name": "Dup"}, + ) + sid = created.json()["id"] + first = await client.post( + f"/api/stations/{sid}/recipes", + headers=auth_headers(admin_user), + json={"recipe_id": recipe.id}, + ) + assert first.status_code == 201 + second = await client.post( + f"/api/stations/{sid}/recipes", + headers=auth_headers(admin_user), + json={"recipe_id": recipe.id}, + ) + assert second.status_code == 409