Merge backend camera di stazione (#72)

This commit is contained in:
2026-10-15 09:53:41 +02:00
7 changed files with 205 additions and 3 deletions
+15
View File
@@ -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,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get a station by its code (any authenticated user, like /by-code/{code}/recipes) (#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,
@@ -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:<seriale>
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')
+32 -3
View File
@@ -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
+3
View File
@@ -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()
+2
View File
@@ -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)
+20
View File
@@ -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")
+100
View File
@@ -430,3 +430,103 @@ 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_any_authenticated_user(
client: AsyncClient, measurement_tec_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(measurement_tec_user))
assert resp.status_code == 200, resp.text
assert resp.json()["code"] == "ST-BA"
async def test_station_by_code_requires_auth(client: AsyncClient):
resp = await client.get("/api/stations/by-code/ST-BA")
assert resp.status_code == 401