Merge lavoro/daitarn-45: worker di visione configurabile per stazione (Fixes #45)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
This commit is contained in:
Adriano
2026-10-11 18:03:57 +00:00
13 changed files with 429 additions and 7 deletions
@@ -0,0 +1,34 @@
"""stations.vision_worker_url e vision_results.worker_url (#45)
stations.vision_worker_url: worker di visione della stazione (NULL = centrale).
vision_results.worker_url: worker effettivamente chiamato per quel risultato.
Revision ID: 019_station_vision_worker
Revises: 018_task_loop_results
Create Date: 2026-10-11
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '019_station_vision_worker'
down_revision: Union[str, None] = '018_task_loop_results'
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('vision_worker_url', sa.String(500), nullable=True))
with op.batch_alter_table('vision_results') as batch:
batch.add_column(sa.Column('worker_url', sa.String(500), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('vision_results') as batch:
batch.drop_column('worker_url')
with op.batch_alter_table('stations') as batch:
batch.drop_column('vision_worker_url')
+22 -1
View File
@@ -2,7 +2,19 @@
from src.backend.models.api.tipi import UtcDateTime from src.backend.models.api.tipi import UtcDateTime
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict, Field 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)."""
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://")
return value.rstrip("/")
class StationCreate(BaseModel): class StationCreate(BaseModel):
@@ -11,6 +23,10 @@ class StationCreate(BaseModel):
location: Optional[str] = Field(default=None, max_length=255) location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None notes: Optional[str] = None
active: bool = True active: bool = True
# worker di visione della stazione (#45); vuoto = centrale
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
class StationUpdate(BaseModel): class StationUpdate(BaseModel):
@@ -18,6 +34,10 @@ class StationUpdate(BaseModel):
location: Optional[str] = Field(default=None, max_length=255) location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None notes: Optional[str] = None
active: Optional[bool] = None 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)
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
class StationResponse(BaseModel): class StationResponse(BaseModel):
@@ -28,6 +48,7 @@ class StationResponse(BaseModel):
location: Optional[str] location: Optional[str]
notes: Optional[str] notes: Optional[str]
active: bool active: bool
vision_worker_url: Optional[str] = None # #45
created_by: int created_by: int
created_at: UtcDateTime created_at: UtcDateTime
+2
View File
@@ -25,6 +25,8 @@ class Station(Base):
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) 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)
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now() DateTime, nullable=False, server_default=func.now()
+2
View File
@@ -26,6 +26,8 @@ class VisionResult(Base):
Integer, ForeignKey("stations.id"), nullable=True Integer, ForeignKey("stations.id"), nullable=True
) )
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# URL del worker effettivamente chiamato (#45), copiato: la stazione puo' cambiarlo dopo
worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Copied, not referenced: a calibration redone six months from now must not # Copied, not referenced: a calibration redone six months from now must not
# rewrite what a measurement already taken means. Same for the graph. # rewrite what a measurement already taken means. Same for the graph.
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
+1
View File
@@ -31,6 +31,7 @@ async def create_station(
location=data.location, location=data.location,
notes=data.notes, notes=data.notes,
active=data.active, active=data.active,
vision_worker_url=data.vision_worker_url, # #45
created_by=creator.id, created_by=creator.id,
) )
db.add(station) db.add(station)
+21 -5
View File
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask, RecipeTask from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
from src.backend.services import measurement_service from src.backend.services import measurement_service
@@ -53,6 +54,7 @@ async def _call_worker(
*, *,
recipe_dir: str | None = None, recipe_dir: str | None = None,
calibration: dict | None = None, calibration: dict | None = None,
worker_url: str | None = None,
) -> dict: ) -> dict:
"""The one place that talks to the worker; the tests replace it. """The one place that talks to the worker; the tests replace it.
@@ -70,6 +72,8 @@ async def _call_worker(
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non # recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
# si mandano, il worker usa i suoi default. # si mandano, il worker usa i suoi default.
data = {"graph": json.dumps(graph)} data = {"graph": json.dumps(graph)}
# worker della stazione (#45) o, se manca, il centrale; mai un ripiego dall'uno all'altro
base = (worker_url or settings.vision_worker_url).rstrip("/")
if recipe_dir: if recipe_dir:
data["recipe_dir"] = recipe_dir data["recipe_dir"] = recipe_dir
if calibration: if calibration:
@@ -77,19 +81,19 @@ async def _call_worker(
try: try:
async with httpx.AsyncClient(timeout=120.0) as client: async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post( response = await client.post(
f"{settings.vision_worker_url}/run", f"{base}/run",
files={"image": ("frame.png", image_bytes, "image/png")}, files={"image": ("frame.png", image_bytes, "image/png")},
data=data, data=data,
) )
except httpx.TimeoutException as exc: except httpx.TimeoutException as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT, status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"the vision worker did not answer in time: {exc}", detail=f"the vision worker did not answer in time: {exc} ({base})", # #45
) from exc ) from exc
except (httpx.TransportError, httpx.HTTPError) as exc: except (httpx.TransportError, httpx.HTTPError) as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc}", detail=f"the vision worker is unreachable: {exc} ({base})", # #45
) from exc ) from exc
# The worker already gave this fault a name - forward its status and # The worker already gave this fault a name - forward its status and
@@ -352,12 +356,20 @@ async def execute_task(
detail="this recipe is not active", detail="this recipe is not active",
) )
run = None
if production_run_id is not None: if production_run_id is not None:
# 404 if the run does not exist, 422 if it belongs to another recipe, # 404 if the run does not exist, 422 if it belongs to another recipe,
# 409 if it is closed or paused - same rule a manual reading follows. # 409 if it is closed or paused - same rule a manual reading follows.
await measurement_service.validate_run_for_version( run = await measurement_service.validate_run_for_version(
db, production_run_id, version_id, db, production_run_id, version_id,
) )
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
station_url = None
if run is not None:
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None
if not task.vision_json: if not task.vision_json:
raise HTTPException( raise HTTPException(
@@ -411,6 +423,7 @@ async def execute_task(
# passa `calibration`, la ricetta porta la sua (#46). # passa `calibration`, la ricetta porta la sua (#46).
report = await _call_worker( report = await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir, image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
worker_url=station_url, # #45
) )
outputs = report["outputs"] outputs = report["outputs"]
@@ -439,7 +452,10 @@ async def execute_task(
image_path=image_path, image_path=image_path,
overlay_path=overlay_path, overlay_path=overlay_path,
engine_version=report["engine_version"], engine_version=report["engine_version"],
executed_on="server", # #45: dove e' girato davvero, e quale worker (copiato come lo snapshot)
executed_on="station" if station_url else "server",
station_id=run.station_id if run is not None else None,
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
graph_snapshot=task.vision_json, graph_snapshot=task.vision_json,
duration_ms=report.get("duration_ms"), duration_ms=report.get("duration_ms"),
) )
+21
View File
@@ -233,3 +233,24 @@ class TestTaskLoopResultsMigration:
with pytest.raises(RuntimeError, match="check"): with pytest.raises(RuntimeError, match="check"):
command.downgrade(cfg, "017_user_pin") command.downgrade(cfg, "017_user_pin")
class TestStationVisionWorkerMigration:
"""019_station_vision_worker: stations.vision_worker_url e vision_results.worker_url, su e giu (#45)."""
def test_019_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 "vision_worker_url" in cols("stations")
assert "worker_url" in cols("vision_results")
command.downgrade(cfg, "018_task_loop_results")
assert "vision_worker_url" not in cols("stations")
assert "worker_url" not in cols("vision_results")
command.upgrade(cfg, "head") # e di nuovo su
assert "vision_worker_url" in cols("stations")
assert "worker_url" in cols("vision_results")
+56
View File
@@ -374,3 +374,59 @@ async def test_reset_unknown_station_is_404(client: AsyncClient, admin_user):
"/api/stations/999999/recipes", headers=auth_headers(admin_user), "/api/stations/999999/recipes", headers=auth_headers(admin_user),
) )
assert resp.status_code == 404 assert resp.status_code == 404
# --- vision_worker_url (#45) ---
async def test_station_vision_worker_url_roundtrip(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-VW", "name": "VW", "vision_worker_url": " http://10.0.0.7:8100/ "},
)
assert created.status_code == 201, created.text
# spazi tolti e "/" finale tolto
assert created.json()["vision_worker_url"] == "http://10.0.0.7:8100"
sid = created.json()["id"]
updated = await client.put(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
json={"vision_worker_url": "https://w.example:9000"},
)
assert updated.status_code == 200
assert updated.json()["vision_worker_url"] == "https://w.example:9000"
# stringa vuota = torna al worker centrale
cleared = await client.put(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
json={"vision_worker_url": ""},
)
assert cleared.status_code == 200
assert cleared.json()["vision_worker_url"] is None
async def test_station_vision_worker_url_defaults_to_null(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-VN", "name": "VN"},
)
assert created.status_code == 201
assert created.json()["vision_worker_url"] is None
async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin_user):
resp = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-VF", "name": "VF", "vision_worker_url": "ftp://x"},
)
assert resp.status_code == 422
created = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-VG", "name": "VG"},
)
resp = await client.put(
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
json={"vision_worker_url": "ftp://x"},
)
assert resp.status_code == 422
@@ -0,0 +1,199 @@
"""#45: il worker di visione si sceglie per stazione.
La stazione si legge dalla sessione di produzione (`production_run_id`): con un
URL sulla stazione `_call_worker` lo riceve e il VisionResult lo registra;
senza URL o senza run si resta sul worker centrale, senza ripiego fra i due.
"""
import io
import httpx
import pytest
from fastapi import HTTPException
from PIL import Image
from src.backend.config import settings
from src.backend.models.api.production import ProductionRunCreate
from src.backend.models.orm.station import Station
from src.backend.models.orm.vision import VisionResult
from src.backend.services import production_service
from src.backend.services.vision_service import _call_worker
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_vision_execute import recipe_parts
from sqlalchemy import select
STATION_URL = "http://10.0.0.7:8100"
def an_image() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
return buffer.getvalue()
@pytest.fixture
def worker_calls(monkeypatch):
"""Fake `_call_worker` che ricorda i kwargs con cui e' stato chiamato."""
calls: list[dict] = []
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
calls.append(kwargs)
return {"outputs": {"t1.diameter": 10.0}, "failures": [],
"engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
return calls
async def _camera_task(db_session, user_id: int):
recipe = await create_test_recipe(db_session, user_id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
await db_session.commit()
return recipe, task
async def _station_with_run(db_session, user, recipe, worker_url):
station = Station(
code="ST-W", name="Stazione W", active=True, created_by=user.id,
vision_worker_url=worker_url,
)
db_session.add(station)
await db_session.commit()
await db_session.refresh(station)
run = await production_service.open_run(
db_session,
ProductionRunCreate(station_code=station.code, recipe_id=recipe.id),
user,
)
await db_session.commit()
return station, run
async def _execute(client, user, task, run=None):
data = {"task_id": str(task.id)}
if run is not None:
data["production_run_id"] = str(run.id)
response = await client.post(
"/api/vision/execute",
headers=auth_headers(user),
files={"image": ("p.png", an_image(), "image/png")},
data=data,
)
assert response.status_code == 200, response.text
async def _the_result(db_session) -> VisionResult:
return (await db_session.execute(select(VisionResult))).scalars().one()
@pytest.mark.asyncio
async def test_station_with_url_gets_the_call_and_the_record(
client, db_session, measurement_tec_user, worker_calls,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(
db_session, measurement_tec_user, recipe, STATION_URL)
await _execute(client, measurement_tec_user, task, run)
assert worker_calls[0]["worker_url"] == STATION_URL
result = await _the_result(db_session)
assert result.executed_on == "station"
assert result.worker_url == STATION_URL
assert result.station_id == station.id
@pytest.mark.asyncio
async def test_station_without_url_uses_the_central_worker(
client, db_session, measurement_tec_user, worker_calls,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(
db_session, measurement_tec_user, recipe, None)
await _execute(client, measurement_tec_user, task, run)
assert worker_calls[0]["worker_url"] is None
result = await _the_result(db_session)
assert result.executed_on == "server"
assert result.worker_url == settings.vision_worker_url.rstrip("/")
assert result.station_id == station.id
@pytest.mark.asyncio
async def test_without_a_run_the_central_worker_is_used(
client, db_session, measurement_tec_user, worker_calls,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
await _execute(client, measurement_tec_user, task)
assert worker_calls[0]["worker_url"] is None
result = await _the_result(db_session)
assert result.executed_on == "server"
assert result.worker_url == settings.vision_worker_url.rstrip("/")
assert result.station_id is None
class _Response:
status_code = 200
def json(self):
return {"outputs": {}, "failures": [], "engine_version": "e" * 40}
class _RecordingClient:
"""httpx.AsyncClient finto: registra gli URL postati, opzionalmente fallisce."""
def __init__(self, posts: list, raises=None):
self._posts = posts
self._raises = raises
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def post(self, url, *args, **kwargs):
self._posts.append(url)
if self._raises is not None:
raise self._raises
return _Response()
def _install(monkeypatch, raises=None) -> list:
posts: list = []
monkeypatch.setattr(
"src.backend.services.vision_service.httpx.AsyncClient",
lambda *a, **k: _RecordingClient(posts, raises),
)
return posts
@pytest.mark.asyncio
async def test_call_worker_posts_to_the_given_url(monkeypatch):
posts = _install(monkeypatch)
# lo "/" finale non deve produrre "//run"
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL + "/")
assert posts == [f"{STATION_URL}/run"]
@pytest.mark.asyncio
async def test_call_worker_defaults_to_the_central_url(monkeypatch):
posts = _install(monkeypatch)
await _call_worker(b"img", {"tools": []})
assert posts == [f"{settings.vision_worker_url.rstrip('/')}/run"]
@pytest.mark.asyncio
async def test_station_worker_down_is_502_with_its_url_and_no_fallback(monkeypatch):
posts = _install(monkeypatch, raises=httpx.ConnectError("refused"))
with pytest.raises(HTTPException) as exc:
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL)
assert exc.value.status_code == 502
assert STATION_URL in exc.value.detail
# una sola POST: il centrale non viene tentato
assert posts == [f"{STATION_URL}/run"]
@@ -204,6 +204,17 @@
maxlength="255"> maxlength="255">
</div> </div>
{# Worker visione (#45): vuoto = worker centrale #}
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Worker visione (URL)') }}</label>
<input type="text" x-model="form.vision_worker_url"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="host:8100"
maxlength="500">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}</p>
</div>
<div> <div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label> <label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
<textarea x-model="form.notes" rows="3" <textarea x-model="form.notes" rows="3"
@@ -452,6 +463,7 @@ function stationManagement(initialStations, initialRecipes) {
name: '', name: '',
location: '', location: '',
notes: '', notes: '',
vision_worker_url: '', // #45
active: true, active: true,
}, },
@@ -505,7 +517,7 @@ function stationManagement(initialStations, initialRecipes) {
this.isEditing = false; this.isEditing = false;
this.editingId = null; this.editingId = null;
this.errorMsg = ''; this.errorMsg = '';
this.form = { code: '', name: '', location: '', notes: '', active: true }; this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', active: true };
this.showModal = true; this.showModal = true;
}, },
@@ -518,6 +530,7 @@ function stationManagement(initialStations, initialRecipes) {
name: station.name, name: station.name,
location: station.location || '', location: station.location || '',
notes: station.notes || '', notes: station.notes || '',
vision_worker_url: station.vision_worker_url || '', // #45
active: station.active, active: station.active,
}; };
this.showModal = true; this.showModal = true;
@@ -537,6 +550,7 @@ function stationManagement(initialStations, initialRecipes) {
name: this.form.name, name: this.form.name,
location: this.form.location || null, location: this.form.location || null,
notes: this.form.notes || null, notes: this.form.notes || null,
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
active: this.form.active, active: this.form.active,
}; };
const resp = await fetch(`/admin/api/stations/${this.editingId}`, { const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
@@ -563,6 +577,7 @@ function stationManagement(initialStations, initialRecipes) {
name: this.form.name, name: this.form.name,
location: this.form.location || null, location: this.form.location || null,
notes: this.form.notes || null, notes: this.form.notes || null,
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
active: this.form.active, active: this.form.active,
}; };
const resp = await fetch('/admin/api/stations', { const resp = await fetch('/admin/api/stations', {
@@ -176,3 +176,42 @@ def test_station_list_page_renders_assigned_recipes(logged_in_client, mock_admin
resp = logged_in_client.get("/admin/stations") resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200 assert resp.status_code == 200
assert b"REC-ABC" in resp.data assert b"REC-ABC" in resp.data
# --- Worker visione per stazione (#45) ---
def test_station_form_has_the_vision_worker_field(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"form.vision_worker_url" in resp.data
# etichetta in italiano o in inglese, secondo il catalogo compilato
assert b"Worker visione (URL)" in resp.data or b"Vision worker (URL)" in resp.data
def test_station_list_exposes_the_vision_worker_url(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, "vision_worker_url": "http://10.0.0.7:8100",
"created_by": 1, "created_at": "2026-04-25T10:00:00"}],
[],
]
resp = logged_in_client.get("/admin/stations")
assert b"http://10.0.0.7:8100" in resp.data # nel JSON iniziale della pagina
def test_station_proxies_pass_the_vision_worker_url_untouched(logged_in_client, mock_admin_api):
mock_admin_api.post.return_value = {"id": 7, "code": "ST-7", "name": "n"}
mock_admin_api.put.return_value = {"id": 7, "code": "ST-7", "name": "n"}
logged_in_client.post(
"/admin/api/stations",
json={"code": "ST-7", "name": "n", "vision_worker_url": "http://w:8100"},
)
mock_admin_api.post.assert_called_once_with(
"/api/stations",
data={"code": "ST-7", "name": "n", "vision_worker_url": "http://w:8100"},
)
logged_in_client.put("/admin/api/stations/7", json={"vision_worker_url": None})
mock_admin_api.put.assert_called_once_with(
"/api/stations/7", data={"vision_worker_url": None},
)
@@ -754,6 +754,14 @@ msgstr "Code cannot be modified"
msgid "Identificativo univoco usato dal client tramite STATION_CODE" msgid "Identificativo univoco usato dal client tramite STATION_CODE"
msgstr "Unique identifier used by client via STATION_CODE" msgstr "Unique identifier used by client via STATION_CODE"
#: templates/admin/stations.html
msgid "Worker visione (URL)"
msgstr "Vision worker (URL)"
#: templates/admin/stations.html
msgid "Vuoto = worker centrale. Indirizzo completo, con http o https"
msgstr "Empty = central worker. Full address, with http or https"
#: templates/admin/stations.html:194 #: templates/admin/stations.html:194
msgid "Nome descrittivo della stazione" msgid "Nome descrittivo della stazione"
msgstr "Descriptive name of the station" msgstr "Descriptive name of the station"
@@ -756,6 +756,14 @@ msgstr "Il codice non può essere modificato"
msgid "Identificativo univoco usato dal client tramite STATION_CODE" msgid "Identificativo univoco usato dal client tramite STATION_CODE"
msgstr "Identificativo univoco usato dal client tramite STATION_CODE" msgstr "Identificativo univoco usato dal client tramite STATION_CODE"
#: templates/admin/stations.html
msgid "Worker visione (URL)"
msgstr "Worker visione (URL)"
#: templates/admin/stations.html
msgid "Vuoto = worker centrale. Indirizzo completo, con http o https"
msgstr "Vuoto = worker centrale. Indirizzo completo, con http o https"
#: templates/admin/stations.html:194 #: templates/admin/stations.html:194
msgid "Nome descrittivo della stazione" msgid "Nome descrittivo della stazione"
msgstr "Nome descrittivo della stazione" msgstr "Nome descrittivo della stazione"