diff --git a/src/backend/api/routers/stations.py b/src/backend/api/routers/stations.py index 3935a16..0333544 100644 --- a/src/backend/api/routers/stations.py +++ b/src/backend/api/routers/stations.py @@ -67,6 +67,21 @@ async def create_new_station( # 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}", response_model=StationResponse) +async def get_station_by_code( + code: str, + admin: User = Depends(require_admin_user), + db: AsyncSession = Depends(get_db), +): + """Get a station by its code (admin only, like GET /{station_id}) (#72).""" + station = await station_service.get_station_by_code(db, code) + if station is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Station not found", + ) + return StationResponse.model_validate(station) + + @router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary]) async def list_recipes_by_station_code( code: str, diff --git a/src/backend/migrations/versions/020_station_camera.py b/src/backend/migrations/versions/020_station_camera.py new file mode 100644 index 0000000..13f7c32 --- /dev/null +++ b/src/backend/migrations/versions/020_station_camera.py @@ -0,0 +1,33 @@ +"""stations.camera_url e stations.camera_source (#72) + +stations.camera_url: servizio camera della stazione (vs-gui o equivalente). +stations.camera_source: sorgente da chiedere al servizio, es. camera: +o camera:folder. + +Revision ID: 020_station_camera +Revises: 019_station_vision_worker +Create Date: 2026-10-15 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '020_station_camera' +down_revision: Union[str, None] = '019_station_vision_worker' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # batch_alter_table come nelle migrazioni vicine: serve a SQLite + with op.batch_alter_table('stations') as batch: + batch.add_column(sa.Column('camera_url', sa.String(500), nullable=True)) + batch.add_column(sa.Column('camera_source', sa.String(200), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('stations') as batch: + batch.drop_column('camera_source') + batch.drop_column('camera_url') diff --git a/src/backend/models/api/station.py b/src/backend/models/api/station.py index 6fb10bc..3024704 100644 --- a/src/backend/models/api/station.py +++ b/src/backend/models/api/station.py @@ -5,18 +5,35 @@ from typing import Optional from pydantic import BaseModel, ConfigDict, Field, field_validator -def _normalize_worker_url(value: Optional[str]) -> Optional[str]: - """Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45).""" +def _normalize_http_url(value: Optional[str], field: str) -> Optional[str]: + """Vuoto/spazi -> None; altrimenti http(s) senza '/' finale.""" if value is None: return None value = value.strip() if not value: return None if not value.lower().startswith(("http://", "https://")): - raise ValueError("vision_worker_url must start with http:// or https://") + raise ValueError(f"{field} must start with http:// or https://") return value.rstrip("/") +def _normalize_worker_url(value: Optional[str]) -> Optional[str]: + """Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45).""" + return _normalize_http_url(value, "vision_worker_url") + + +def _normalize_camera_url(value: Optional[str]) -> Optional[str]: + """Vuoto/spazi -> None (nessuna camera); altrimenti http(s) senza '/' finale (#72).""" + return _normalize_http_url(value, "camera_url") + + +def _normalize_camera_source(value: Optional[str]) -> Optional[str]: + """Strip; vuoto -> None (#72).""" + if value is None: + return None + return value.strip() or None + + class StationCreate(BaseModel): code: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=255) @@ -25,8 +42,13 @@ class StationCreate(BaseModel): active: bool = True # worker di visione della stazione (#45); vuoto = centrale vision_worker_url: Optional[str] = Field(default=None, max_length=500) + # camera della stazione (#72); vuoto = nessuna camera + camera_url: Optional[str] = Field(default=None, max_length=500) + camera_source: Optional[str] = Field(default=None, max_length=200) _check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url) + _check_camera_url = field_validator("camera_url")(_normalize_camera_url) + _check_camera_source = field_validator("camera_source")(_normalize_camera_source) class StationUpdate(BaseModel): @@ -36,8 +58,13 @@ class StationUpdate(BaseModel): active: Optional[bool] = None # worker di visione della stazione (#45); "" -> None riporta al centrale vision_worker_url: Optional[str] = Field(default=None, max_length=500) + # camera della stazione (#72); vuoto = nessuna camera + camera_url: Optional[str] = Field(default=None, max_length=500) + camera_source: Optional[str] = Field(default=None, max_length=200) _check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url) + _check_camera_url = field_validator("camera_url")(_normalize_camera_url) + _check_camera_source = field_validator("camera_source")(_normalize_camera_source) class StationResponse(BaseModel): @@ -49,6 +76,8 @@ class StationResponse(BaseModel): notes: Optional[str] active: bool vision_worker_url: Optional[str] = None # #45 + camera_url: Optional[str] = None # #72 + camera_source: Optional[str] = None # #72 created_by: int created_at: UtcDateTime diff --git a/src/backend/models/orm/station.py b/src/backend/models/orm/station.py index 973bcaa..4d2cf32 100644 --- a/src/backend/models/orm/station.py +++ b/src/backend/models/orm/station.py @@ -27,6 +27,9 @@ class Station(Base): active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) # URL del worker di visione della stazione (#45); NULL = worker centrale vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + # camera della stazione (#72): URL del servizio camera e sorgente da chiedergli + camera_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + camera_source: Mapped[Optional[str]] = mapped_column(String(200), nullable=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() diff --git a/src/backend/services/station_service.py b/src/backend/services/station_service.py index eb35dfa..14c4a31 100644 --- a/src/backend/services/station_service.py +++ b/src/backend/services/station_service.py @@ -32,6 +32,8 @@ async def create_station( notes=data.notes, active=data.active, vision_worker_url=data.vision_worker_url, # #45 + camera_url=data.camera_url, # #72 + camera_source=data.camera_source, # #72 created_by=creator.id, ) db.add(station) diff --git a/src/backend/tests/test_migrations.py b/src/backend/tests/test_migrations.py index 613e426..5d4baf4 100644 --- a/src/backend/tests/test_migrations.py +++ b/src/backend/tests/test_migrations.py @@ -321,3 +321,23 @@ class TestStationVisionWorkerMigration: command.upgrade(cfg, "head") # e di nuovo su assert "vision_worker_url" in cols("stations") assert "worker_url" in cols("vision_results") + + +class TestStationCameraMigration: + """020_station_camera: stations.camera_url e stations.camera_source, su e giu (#72).""" + + def test_020_upgrade_and_downgrade(self, sqlite_database_url): + db_path, _ = sqlite_database_url + cfg = _alembic_config() + + def cols(table): + return {c["name"] for c in _inspect(db_path).get_columns(table)} + + command.upgrade(cfg, "head") + assert {"camera_url", "camera_source"} <= cols("stations") + command.downgrade(cfg, "019_station_vision_worker") + assert "camera_url" not in cols("stations") + assert "camera_source" not in cols("stations") + assert "vision_worker_url" in cols("stations") # la 019 resta + command.upgrade(cfg, "head") # e di nuovo su + assert {"camera_url", "camera_source"} <= cols("stations") diff --git a/src/backend/tests/test_stations_api.py b/src/backend/tests/test_stations_api.py index 87551d2..7b15eca 100644 --- a/src/backend/tests/test_stations_api.py +++ b/src/backend/tests/test_stations_api.py @@ -430,3 +430,95 @@ async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin json={"vision_worker_url": "ftp://x"}, ) assert resp.status_code == 422 + + +# --- camera_url / camera_source (#72) --- + +async def test_station_camera_fields_roundtrip(client: AsyncClient, admin_user): + created = await client.post( + "/api/stations", headers=auth_headers(admin_user), + json={ + "code": "ST-CAM", "name": "CAM", + "camera_url": " http://127.0.0.1:8765/ ", + "camera_source": " camera:folder ", + }, + ) + assert created.status_code == 201, created.text + assert created.json()["camera_url"] == "http://127.0.0.1:8765" + assert created.json()["camera_source"] == "camera:folder" + + sid = created.json()["id"] + read = await client.get(f"/api/stations/{sid}", headers=auth_headers(admin_user)) + assert read.status_code == 200 + assert read.json()["camera_url"] == "http://127.0.0.1:8765" + assert read.json()["camera_source"] == "camera:folder" + + updated = await client.put( + f"/api/stations/{sid}", headers=auth_headers(admin_user), + json={"camera_url": "https://cam.example:9000", "camera_source": "camera:ABC123"}, + ) + assert updated.status_code == 200 + assert updated.json()["camera_url"] == "https://cam.example:9000" + assert updated.json()["camera_source"] == "camera:ABC123" + + cleared = await client.put( + f"/api/stations/{sid}", headers=auth_headers(admin_user), + json={"camera_url": "", "camera_source": " "}, + ) + assert cleared.status_code == 200 + assert cleared.json()["camera_url"] is None + assert cleared.json()["camera_source"] is None + + +async def test_station_camera_fields_default_to_null(client: AsyncClient, admin_user): + created = await client.post( + "/api/stations", headers=auth_headers(admin_user), + json={"code": "ST-CN", "name": "CN"}, + ) + assert created.status_code == 201 + assert created.json()["camera_url"] is None + assert created.json()["camera_source"] is None + + +async def test_station_camera_url_must_be_http(client: AsyncClient, admin_user): + resp = await client.post( + "/api/stations", headers=auth_headers(admin_user), + json={"code": "ST-CF", "name": "CF", "camera_url": "ftp://x"}, + ) + assert resp.status_code == 422 + + created = await client.post( + "/api/stations", headers=auth_headers(admin_user), + json={"code": "ST-CG", "name": "CG"}, + ) + resp = await client.put( + f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user), + json={"camera_url": "ftp://x"}, + ) + assert resp.status_code == 422 + + +async def test_station_by_code_returns_station(client: AsyncClient, admin_user): + await client.post( + "/api/stations", headers=auth_headers(admin_user), + json={"code": "ST-BC", "name": "BC", "camera_url": "http://h:1", "camera_source": "camera:folder"}, + ) + resp = await client.get("/api/stations/by-code/ST-BC", headers=auth_headers(admin_user)) + assert resp.status_code == 200, resp.text + assert resp.json()["code"] == "ST-BC" + assert resp.json()["camera_url"] == "http://h:1" + assert resp.json()["camera_source"] == "camera:folder" + + +async def test_station_by_code_unknown_is_404(client: AsyncClient, admin_user): + resp = await client.get("/api/stations/by-code/NOPE", headers=auth_headers(admin_user)) + assert resp.status_code == 404 + + +async def test_station_by_code_requires_admin(client: AsyncClient, maker_user, admin_user): + await client.post( + "/api/stations", headers=auth_headers(admin_user), + json={"code": "ST-BA", "name": "BA"}, + ) + resp = await client.get("/api/stations/by-code/ST-BA", headers=auth_headers(maker_user)) + assert resp.status_code == 403