From 338f21fba093054dd7e161a5eac524df47d89c67 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 22:37:16 +0200 Subject: [PATCH] feat(api): add /api/stations router with CRUD and assignments Implements the /api/stations FastAPI router (admin-only CRUD, recipe assignment endpoints) and the public /by-code/{code}/recipes operator endpoint. Registers the router in main.py and adds 8 integration tests. --- server/main.py | 2 + server/routers/stations.py | 138 ++++++++++++++++++++++++++++++ server/tests/test_stations_api.py | 136 +++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 server/routers/stations.py create mode 100644 server/tests/test_stations_api.py 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/routers/stations.py b/server/routers/stations.py new file mode 100644 index 0000000..5920610 --- /dev/null +++ b/server/routers/stations.py @@ -0,0 +1,138 @@ +"""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) + + +@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=f"Station '{code}' not found or inactive", + ) + 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/tests/test_stations_api.py b/server/tests/test_stations_api.py new file mode 100644 index 0000000..31058a1 --- /dev/null +++ b/server/tests/test_stations_api.py @@ -0,0 +1,136 @@ +"""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