Compare commits

...

14 Commits

Author SHA1 Message Date
Adriano 011c08c9d8 vendor visionsuite adf7a26 -> cdc7a14: revisione GUI e assistente (visionsuite#305-#316) (#73)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcYsFQDaTPnDVjrQkWj8R
2026-10-13 09:26:00 +02:00
Adriano 82ad88538d Merge lavoro/daitarn-70: versione e commit nel piè di pagina (Fixes #70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019u7uKckaZ7DBe4oGbiwuHa
2026-10-12 14:25:04 +02:00
Adriano bafc776ef7 Versione e commit nel piè di pagina (#70)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:22:02 +02:00
Adriano 8fcb4796d5 Merge lavoro/daitarn-58: esiti, note, campi e allegati dei task nella statistica per sessione (Fixes #58)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:22:39 +00:00
Adriano 8d7fa9a12a Statistica: pannello esiti, note, campi e allegati dei task per sessione, proxy e traduzioni (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:08:18 +00:00
Adriano c911449b7c Statistica: endpoint esiti, note, campi e allegati dei task di una sessione (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:07:06 +00:00
Adriano ac8604eff5 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
2026-10-11 18:03:57 +00:00
Adriano 631ba4ee05 Stazioni: placeholder senza URL letterale (test_offline), test form indipendente dalla lingua (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:49:46 +00:00
Adriano 40eed5c2af Admin stazioni: campo Worker visione (URL), traduzioni e test (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:39:22 +00:00
Adriano 275bbd1a5f Test worker di visione per stazione, API stazioni, migrazione 019 (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:38:03 +00:00
Adriano 95d643d6d8 Worker di visione per stazione: modello, API, migrazione 019, execute_task (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:37:08 +00:00
Adriano 9306fbf653 Merge lavoro/daitarn-tmf69: vendor visionsuite adf7a26 (Fixes #69)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 16:44:06 +02:00
Adriano 1a5e98f072 vendor visionsuite adf7a26: ricette robuste ai PermissionError di Windows (#69)
Da dbd1a55 a adf7a26 (visionsuite#279): vs-task server/recipes.py ritenta lettura e scrittura su PermissionError. uv.lock invariato (uv lock --check ok). Suite visione 3.13: 34 passati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 16:44:03 +02:00
Adriano fdfcf05951 Merge lavoro/daitarn-tmf68: vendor visionsuite riallineato a main dbd1a55 (Fixes #68)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 14:07:03 +02:00
28 changed files with 939 additions and 12 deletions
+7
View File
@@ -47,6 +47,13 @@ WORKDIR /app/flask_app
RUN uv run --project /app pybabel compile -d translations && \
chown -R app:app /app /tmp/uv-cache
# Commit mostrato nel piede di pagina (#70). .git e' escluso dal contesto di
# build (.dockerignore), quindi lo passa chi costruisce; facoltativo, senza il
# piede mostra solo la versione. Uso:
# APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client
ARG APP_COMMIT=""
ENV APP_COMMIT=${APP_COMMIT}
USER app
EXPOSE 5000
+1
View File
@@ -333,6 +333,7 @@ TieMeasureFlow/
|---|---|
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
| `APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client` | Ricostruisci il client mostrando nel piè di pagina il commit (oltre alla versione di `pyproject.toml`); senza `APP_COMMIT` compare solo la versione |
| `docker compose logs -f server` | Segui log server in tempo reale |
| `docker compose logs -f client` | Segui log client in tempo reale |
| `docker compose ps` | Stato di tutti i servizi |
+3
View File
@@ -75,6 +75,9 @@ services:
build:
context: .
dockerfile: Dockerfile.frontend
args:
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
APP_COMMIT: ${APP_COMMIT:-}
container_name: tmflow-client
restart: unless-stopped
env_file:
+3
View File
@@ -89,6 +89,9 @@ services:
build:
context: .
dockerfile: Dockerfile.frontend
args:
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
APP_COMMIT: ${APP_COMMIT:-}
container_name: tmflow-client
restart: unless-stopped
env_file:
+74
View File
@@ -12,6 +12,7 @@ from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
from src.backend.models.orm.user import User
from src.backend.models.api.statistics import (
CapabilityData,
@@ -294,3 +295,76 @@ async def get_recipe_runs(
}
for r in runs
]
@router.get("/runs/{run_id}/task-results")
async def get_run_task_results(
run_id: int,
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> dict:
"""#58: esiti, note, campi e allegati dei task di una sessione di produzione.
Endpoint di statistica (require_metrologist): GET /api/task-results vuole
il ruolo MeasurementTec e non si puo' riusare qui.
"""
run = (await db.execute(
select(ProductionRun.id).where(ProductionRun.id == run_id)
)).scalar_one_or_none()
if run is None:
raise HTTPException(status_code=404, detail="Sessione non trovata")
# #58: una query per i risultati della run, una per i task, una per gli utenti.
results = (await db.execute(
select(TaskResult)
.where(TaskResult.production_run_id == run_id)
.order_by(TaskResult.created_at.asc(), TaskResult.id.asc())
)).scalars().all()
task_ids = {r.task_id for r in results}
tasks = {}
if task_ids:
tasks = {t.id: t for t in (await db.execute(
select(RecipeTask).where(RecipeTask.id.in_(task_ids))
)).scalars().all()}
user_ids = {r.user_id for r in results}
names = {}
if user_ids:
names = dict((await db.execute(
select(User.id, User.display_name).where(User.id.in_(user_ids))
)).all())
counts = {"ok": 0, "nok": 0, "done": 0}
out = []
for r in results:
counts[r.outcome] = counts.get(r.outcome, 0) + 1
task = tasks.get(r.task_id)
# #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave.
labels = {
f.get("key"): f.get("label") or f.get("key")
for f in (task.fields or []) if isinstance(f, dict)
} if task else {}
out.append({
"id": r.id,
"task_id": r.task_id,
"task_title": task.title if task else None,
"task_type": task.task_type if task else None,
"outcome": r.outcome,
"note": r.note,
"created_at": r.created_at.isoformat() if r.created_at else None,
"user_name": names.get(r.user_id),
"values": [
{"key": k, "label": labels.get(k, k), "value": v}
for k, v in (r.values or {}).items()
],
"attachments": [
{
"key": a.get("key"),
"label": labels.get(a.get("key"), a.get("key")),
"name": a.get("name"),
"path": a.get("path"),
}
for a in (r.attachments or []) if isinstance(a, dict)
],
})
return {"run_id": run_id, "counts": counts, "results": out}
@@ -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 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):
@@ -11,6 +23,10 @@ class StationCreate(BaseModel):
location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None
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):
@@ -18,6 +34,10 @@ class StationUpdate(BaseModel):
location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = 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):
@@ -28,6 +48,7 @@ class StationResponse(BaseModel):
location: Optional[str]
notes: Optional[str]
active: bool
vision_worker_url: Optional[str] = None # #45
created_by: int
created_at: UtcDateTime
+2
View File
@@ -25,6 +25,8 @@ class Station(Base):
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)
# 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_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
+2
View File
@@ -26,6 +26,8 @@ class VisionResult(Base):
Integer, ForeignKey("stations.id"), 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
# rewrite what a measurement already taken means. Same for the graph.
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
+1
View File
@@ -31,6 +31,7 @@ async def create_station(
location=data.location,
notes=data.notes,
active=data.active,
vision_worker_url=data.vision_worker_url, # #45
created_by=creator.id,
)
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.models.orm.measurement import Measurement
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.vision import VisionReferenceImage, VisionResult
from src.backend.services import measurement_service
@@ -53,6 +54,7 @@ async def _call_worker(
*,
recipe_dir: str | None = None,
calibration: dict | None = None,
worker_url: str | None = None,
) -> dict:
"""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
# si mandano, il worker usa i suoi default.
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:
data["recipe_dir"] = recipe_dir
if calibration:
@@ -77,19 +81,19 @@ async def _call_worker(
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{settings.vision_worker_url}/run",
f"{base}/run",
files={"image": ("frame.png", image_bytes, "image/png")},
data=data,
)
except httpx.TimeoutException as exc:
raise HTTPException(
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
except (httpx.TransportError, httpx.HTTPError) as exc:
raise HTTPException(
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
# 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",
)
run = None
if production_run_id is not None:
# 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.
await measurement_service.validate_run_for_version(
run = await measurement_service.validate_run_for_version(
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:
raise HTTPException(
@@ -411,6 +423,7 @@ async def execute_task(
# passa `calibration`, la ricetta porta la sua (#46).
report = await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
worker_url=station_url, # #45
)
outputs = report["outputs"]
@@ -439,7 +452,10 @@ async def execute_task(
image_path=image_path,
overlay_path=overlay_path,
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,
duration_ms=report.get("duration_ms"),
)
+21
View File
@@ -233,3 +233,24 @@ class TestTaskLoopResultsMigration:
with pytest.raises(RuntimeError, match="check"):
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),
)
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
+113
View File
@@ -287,3 +287,116 @@ class TestRunFilter:
headers=auth_headers(measurement_tec_user),
params={"recipe_id": 1})
assert resp.status_code == 403
class TestRunTaskResults:
"""#58: GET /api/statistics/runs/{id}/task-results."""
FIELDS = [
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
]
async def _setup(self, client, db_session, maker_user, tec_user):
from datetime import datetime
from sqlalchemy import select
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task_result import TaskResult
recipe = await create_test_recipe(db_session, maker_user.id, code="TR58")
version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().first()
db_session.add(Station(code="ST-TR58", name="S", active=True, created_by=maker_user.id))
await db_session.commit()
station = (await db_session.execute(select(Station))).scalars().first()
run = ProductionRun(
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
operator_id=tec_user.id, status="open", started_at=datetime(2026, 1, 1),
)
db_session.add(run)
await db_session.commit()
h = auth_headers(maker_user)
check = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=h,
json={"title": "Controllo", "task_type": "check"},
)).json()
note = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=h,
json={"title": "Nota", "task_type": "note", "fields": self.FIELDS},
)).json()
# Inseriti fuori ordine: l'API deve ordinare per created_at.
rows = [
TaskResult(task_id=note["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="done", note="ok finito",
values={"peso": "12", "foto": "x.jpg"},
attachments=[{"key": "foto", "path": "task_results/a.jpg", "name": "a.jpg"}],
created_at=datetime(2026, 1, 1, 10, 3)),
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="nok", note="graffio",
created_at=datetime(2026, 1, 1, 10, 2)),
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="ok",
created_at=datetime(2026, 1, 1, 10, 1)),
# Un esito di un'altra sessione (nessuna) non deve comparire.
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=None,
user_id=tec_user.id, outcome="ok",
created_at=datetime(2026, 1, 1, 9, 0)),
]
db_session.add_all(rows)
await db_session.commit()
return recipe, run, check, note
async def test_counts_order_labels_attachments(
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
):
_, run, check, note = await self._setup(
client, db_session, maker_user, measurement_tec_user)
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
headers=auth_headers(metrologist_user))
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["run_id"] == run.id
assert data["counts"] == {"ok": 1, "nok": 1, "done": 1}
res = data["results"]
assert [r["outcome"] for r in res] == ["ok", "nok", "done"] # per ora crescente
assert res[1]["note"] == "graffio"
assert res[1]["task_title"] == "Controllo"
assert res[1]["task_type"] == "check"
assert res[1]["user_name"] == measurement_tec_user.display_name
assert res[1]["values"] == [] and res[1]["attachments"] == []
n = res[2]
assert n["task_title"] == "Nota" and n["note"] == "ok finito"
assert n["values"] == [
{"key": "peso", "label": "Peso", "value": "12"},
{"key": "foto", "label": "Foto", "value": "x.jpg"},
]
assert n["attachments"] == [
{"key": "foto", "label": "Foto", "name": "a.jpg", "path": "task_results/a.jpg"},
]
async def test_label_falls_back_to_key_when_field_removed(
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
):
from src.backend.models.orm.task import RecipeTask
_, run, _, note = await self._setup(
client, db_session, maker_user, measurement_tec_user)
task = await db_session.get(RecipeTask, note["id"])
task.fields = [self.FIELDS[0]] # il campo «foto» viene tolto dal task
await db_session.commit()
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
headers=auth_headers(metrologist_user))
n = resp.json()["results"][2]
assert {v["key"]: v["label"] for v in n["values"]} == {"peso": "Peso", "foto": "foto"}
assert n["attachments"][0]["label"] == "foto"
async def test_unknown_run_is_404(self, client, metrologist_user):
resp = await client.get("/api/statistics/runs/99999/task-results",
headers=auth_headers(metrologist_user))
assert resp.status_code == 404
async def test_measurement_tec_is_403(self, client, measurement_tec_user):
resp = await client.get("/api/statistics/runs/1/task-results",
headers=auth_headers(measurement_tec_user))
assert resp.status_code == 403
@@ -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"]
+3
View File
@@ -242,6 +242,9 @@ def create_app() -> Flask:
"company_logo": session.get("company_logo"),
"auto_logout_minutes": session.get("auto_logout_minutes"),
"current_year": date.today().year,
# Versione/commit nel piede di pagina (#70)
"app_version": Config.APP_VERSION,
"app_commit": Config.APP_COMMIT,
}
return app
@@ -1,12 +1,13 @@
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
import requests as http_requests
from flask import Blueprint, jsonify, render_template, request, session
from flask import Blueprint, abort, jsonify, render_template, request, session
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
from config import Config
from services.api_client import api_client
from services.file_proxy import proxy_file # #58: allegati dei task
statistics_bp = Blueprint("statistics", __name__)
@@ -148,6 +149,33 @@ def api_runs():
return jsonify(resp)
@statistics_bp.route("/api/runs/<int:run_id>/task-results")
@login_required
@role_required("Metrologist")
def api_run_task_results(run_id: int):
"""Proxy: esiti, note, campi e allegati dei task di una sessione (#58)."""
resp = api_client.get(f"/api/statistics/runs/{run_id}/task-results")
if isinstance(resp, dict) and resp.get("error"):
error_body = {"error": True, "detail": resp.get("detail", "")}
if resp.get("session_expired"):
error_body["session_expired"] = True
return jsonify(error_body), resp.get("status_code", 500)
return jsonify(resp)
@statistics_bp.route("/api/files/<path:file_path>")
@login_required
@role_required("Metrologist")
def api_task_result_file(file_path: str):
"""Proxy: allegati dei task (#58). Solo task_results/, mai altri file."""
# #58: il Metrologist non deve poter leggere altro dell'upload dir (es. statistics/).
if not file_path.startswith("task_results/") or ".." in file_path.split("/"):
abort(404)
return proxy_file(file_path)
# ============================================================================
# PROXY REPORT PDF → FastAPI
# ============================================================================
+27
View File
@@ -1,5 +1,7 @@
"""TieMeasureFlow Client Configuration."""
import os
import tomllib
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
@@ -10,6 +12,25 @@ load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
def _read_app_version() -> str:
"""Versione del prodotto = `[project] version` di pyproject.toml (#70).
Si risale dalla cartella del modulo: nel container il file sta in
/app/pyproject.toml (Dockerfile.frontend), nel repo alla radice. Se non
si trova o e' illeggibile torna "dev": il piede di pagina non deve mai
rompere l'avvio.
"""
for parent in Path(__file__).resolve().parents:
candidate = parent / "pyproject.toml"
if candidate.is_file():
try:
with candidate.open("rb") as fh:
return str(tomllib.load(fh)["project"]["version"])
except (OSError, tomllib.TOMLDecodeError, KeyError):
return "dev"
return "dev"
class Config:
"""Flask client configuration."""
@@ -17,6 +38,12 @@ class Config:
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
# Versione e commit mostrati nel piede di pagina (#70). Letti una volta
# all'import. Il commit arriva dal build arg APP_COMMIT (.git non e' nel
# contesto di build, vedi .dockerignore); facoltativo: vuoto = non mostrato.
APP_VERSION: str = _read_app_version()
APP_COMMIT: str = os.environ.get("APP_COMMIT", "")[:7]
# API Server connection
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
@@ -204,6 +204,17 @@
maxlength="255">
</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>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
<textarea x-model="form.notes" rows="3"
@@ -452,6 +463,7 @@ function stationManagement(initialStations, initialRecipes) {
name: '',
location: '',
notes: '',
vision_worker_url: '', // #45
active: true,
},
@@ -505,7 +517,7 @@ function stationManagement(initialStations, initialRecipes) {
this.isEditing = false;
this.editingId = null;
this.errorMsg = '';
this.form = { code: '', name: '', location: '', notes: '', active: true };
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', active: true };
this.showModal = true;
},
@@ -518,6 +530,7 @@ function stationManagement(initialStations, initialRecipes) {
name: station.name,
location: station.location || '',
notes: station.notes || '',
vision_worker_url: station.vision_worker_url || '', // #45
active: station.active,
};
this.showModal = true;
@@ -537,6 +550,7 @@ function stationManagement(initialStations, initialRecipes) {
name: this.form.name,
location: this.form.location || null,
notes: this.form.notes || null,
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
active: this.form.active,
};
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
@@ -563,6 +577,7 @@ function stationManagement(initialStations, initialRecipes) {
name: this.form.name,
location: this.form.location || null,
notes: this.form.notes || null,
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
active: this.form.active,
};
const resp = await fetch('/admin/api/stations', {
@@ -157,7 +157,7 @@
<!-- Footer -->
<div class="text-center text-xs text-[var(--text-muted)]">
<p>TieMeasureFlow &copy; {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
<p>TieMeasureFlow &copy; {{ current_year }} - {{ _('Sistema di misurazione industriale') }}{# versione e commit (#70) #} &middot; v{{ app_version }}{% if app_commit %} &middot; {{ app_commit }}{% endif %}</p>
</div>
</div>
</div>
+1 -1
View File
@@ -107,7 +107,7 @@
<!-- Footer -->
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
Powered by <span class="font-semibold">TieMeasureFlow</span> &mdash; Tielogic
Powered by <span class="font-semibold">TieMeasureFlow</span> &mdash; Tielogic{# versione e commit (#70) #} &middot; v{{ app_version }}{% if app_commit %} &middot; {{ app_commit }}{% endif %}
</p>
</footer>
{% endblock %}
@@ -129,6 +129,74 @@
</div>
</div>
<!-- #58: esiti, note, campi e allegati dei task della sessione scelta -->
<div id="run-task-results" x-show="selectedRunId" x-cloak
class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 mb-6 shadow-sm">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Esiti dei task della sessione") }}</h2>
<p x-show="taskResultsLoading" class="text-sm text-steel">{{ _("Caricamento...") }}</p>
<p x-show="taskResultsError" x-text="taskResultsError" class="text-sm text-red-600"></p>
<template x-if="taskResults && !taskResultsLoading">
<div>
<div class="grid grid-cols-3 gap-3 mb-4 max-w-md">
<div class="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
<div class="text-2xl font-bold font-mono text-emerald-600" x-text="taskResults.counts.ok"></div>
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("OK") }}</div>
</div>
<div class="text-center p-3 rounded-lg bg-red-50 dark:bg-red-900/20">
<div class="text-2xl font-bold font-mono text-red-600" x-text="taskResults.counts.nok"></div>
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("NOK") }}</div>
</div>
<div class="text-center p-3 rounded-lg bg-[var(--bg-primary)]">
<div class="text-2xl font-bold font-mono text-[var(--text-primary)]" x-text="taskResults.counts.done"></div>
<div class="text-xs text-steel mt-1">{{ _("Fatto") }}</div>
</div>
</div>
<p x-show="taskResults.results.length === 0" class="text-sm text-steel">{{ _("Nessun esito registrato in questa sessione") }}</p>
<div x-show="taskResults.results.length > 0" class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-steel uppercase border-b border-[var(--border-color)]">
<th class="py-2 pr-3">{{ _("Ora") }}</th>
<th class="py-2 pr-3">{{ _("Task") }}</th>
<th class="py-2 pr-3">{{ _("Esito") }}</th>
<th class="py-2 pr-3">{{ _("Note") }}</th>
<th class="py-2 pr-3">{{ _("Campi") }}</th>
<th class="py-2 pr-3">{{ _("Allegati") }}</th>
<th class="py-2">{{ _("Operatore") }}</th>
</tr>
</thead>
<tbody>
<template x-for="r in taskResults.results" :key="r.id">
<tr class="border-b border-[var(--border-color)] align-top">
<td class="py-2 pr-3 font-mono whitespace-nowrap" x-text="resultTime(r.created_at)"></td>
<td class="py-2 pr-3" x-text="r.task_title"></td>
<td class="py-2 pr-3">
<span class="px-2 py-0.5 rounded text-xs font-semibold"
:class="r.outcome === 'nok' ? 'bg-red-600 text-white' : (r.outcome === 'ok' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-700')"
x-text="outcomeLabel(r.outcome)"></span>
</td>
<td class="py-2 pr-3 whitespace-pre-line" x-text="r.note"></td>
<td class="py-2 pr-3">
<template x-for="v in r.values" :key="v.key">
<div><span class="text-steel" x-text="v.label + ': '"></span><span x-text="v.value"></span></div>
</template>
</td>
<td class="py-2 pr-3">
<template x-for="a in r.attachments" :key="a.path">
<div><a :href="attachmentUrl(a.path)" target="_blank" rel="noopener"
class="text-primary hover:underline" x-text="a.name || a.label"></a></div>
</template>
</td>
<td class="py-2" x-text="r.user_name"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
<!-- Nessuna ricetta selezionata -->
<template x-if="!selectedRecipeId && !loading">
<div class="text-center py-16 text-steel">
@@ -290,8 +358,52 @@ function spcDashboard() {
reportError: '',
downloadingReport: false,
// #58: esiti dei task della sessione scelta
taskResults: null,
taskResultsLoading: false,
taskResultsError: '',
init() {
// Nothing to pre-load
// #58: il pannello esiti si ricarica a ogni cambio di sessione
this.$watch('selectedRunId', () => this.loadTaskResults());
},
async loadTaskResults() {
this.taskResults = null;
this.taskResultsError = '';
const runId = this.selectedRunId;
if (!runId) return;
this.taskResultsLoading = true;
try {
const resp = await fetch('/statistics/api/runs/' + runId + '/task-results');
const data = await resp.json();
// #58: se nel frattempo la sessione e' cambiata, il risultato e' vecchio
if (runId !== this.selectedRunId) return;
if (!resp.ok || data.error) {
this.taskResultsError = data.detail || {{ _("Errore nel caricamento dei dati")|tojson }};
} else {
this.taskResults = data;
}
} catch (e) {
console.error('Error loading task results:', e);
this.taskResultsError = {{ _("Errore di connessione al server")|tojson }};
} finally {
if (runId === this.selectedRunId) this.taskResultsLoading = false;
}
},
// #58: ora locale dell'esito (created_at e' UTC senza suffisso)
resultTime(iso) {
return iso ? new Date(iso + (iso.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
},
outcomeLabel(o) {
return o === 'ok' ? {{ _("OK")|tojson }} : (o === 'nok' ? {{ _("NOK")|tojson }} : {{ _("Fatto")|tojson }});
},
// #58: gli allegati passano dal proxy del blueprint, il browser non manda X-API-Key
attachmentUrl(path) {
return '/statistics/api/files/' + String(path).split('/').map(encodeURIComponent).join('/');
},
async onRecipeChange() {
@@ -176,3 +176,42 @@ def test_station_list_page_renders_assigned_recipes(logged_in_client, mock_admin
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200
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},
)
@@ -0,0 +1,37 @@
"""Versione e commit nel piede di pagina (#70)."""
import tomllib
from pathlib import Path
from config import Config
_PYPROJECT = Path(__file__).resolve().parents[4] / "pyproject.toml"
def _pyproject_version() -> str:
with _PYPROJECT.open("rb") as fh:
return tomllib.load(fh)["project"]["version"]
def test_app_version_matches_pyproject():
# La versione mostrata e' quella di pyproject.toml, non un valore a mano
assert Config.APP_VERSION == _pyproject_version()
def test_login_page_shows_version(client):
resp = client.get("/auth/login")
assert resp.status_code == 200
assert f"v{_pyproject_version()}" in resp.get_data(as_text=True)
def test_login_page_shows_commit_when_set(client, monkeypatch):
# Commit valorizzato (gia' troncato a 7 da Config): compare nel footer
monkeypatch.setattr(Config, "APP_COMMIT", "abc1234")
assert "abc1234" in client.get("/auth/login").get_data(as_text=True)
def test_login_page_hides_commit_when_empty(client, monkeypatch):
# Build senza APP_COMMIT: solo versione, nessun separatore orfano
monkeypatch.setattr(Config, "APP_COMMIT", "")
html = client.get("/auth/login").get_data(as_text=True)
assert f"v{_pyproject_version()}" in html
assert f"v{_pyproject_version()} &middot;" not in html
@@ -130,3 +130,40 @@ class TestRunsProxy:
mock_api_client.get.return_value = {"total": 1}
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
class TestRunTaskResultsProxy:
"""#58: esiti dei task e allegati della sessione."""
def test_task_results_proxy(self, logged_in_client, mock_api_client):
payload = {"run_id": 5, "counts": {"ok": 1, "nok": 0, "done": 0}, "results": []}
mock_api_client.get.return_value = payload
resp = logged_in_client.get("/statistics/api/runs/5/task-results")
assert resp.status_code == 200
assert resp.get_json() == payload
assert mock_api_client.get.call_args.args[0] == "/api/statistics/runs/5/task-results"
def test_task_results_proxy_relays_error(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"error": True, "detail": "x", "status_code": 404}
resp = logged_in_client.get("/statistics/api/runs/9/task-results")
assert resp.status_code == 404
assert resp.get_json()["error"] is True
def test_file_proxy_serves_task_results_only(self, logged_in_client):
from unittest.mock import patch
from flask import Response
with patch("blueprints.statistics.proxy_file",
return_value=Response("img", status=200)) as pf:
ok = logged_in_client.get("/statistics/api/files/task_results/a.jpg")
assert ok.status_code == 200
pf.assert_called_once_with("task_results/a.jpg")
for bad in ("statistics/x", "task_results/../statistics/x", "../etc/passwd"):
assert logged_in_client.get(f"/statistics/api/files/{bad}").status_code == 404
assert pf.call_count == 1 # i percorsi rifiutati non arrivano al proxy
def test_dashboard_has_task_results_panel(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
assert 'id="run-task-results"' in html
assert "loadTaskResults" in html
assert "/statistics/api/runs/" in html
@@ -754,6 +754,14 @@ msgstr "Code cannot be modified"
msgid "Identificativo univoco usato dal client tramite 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
msgid "Nome descrittivo della stazione"
msgstr "Descriptive name of the station"
@@ -2969,6 +2977,36 @@ msgstr "Select a measurement point for the histogram"
msgid "Errore nella generazione del report"
msgstr "Error generating report"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Esiti dei task della sessione"
msgstr "Task outcomes of the session"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Nessun esito registrato in questa sessione"
msgstr "No outcome recorded in this session"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Fatto"
msgstr "Done"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Ora"
msgstr "Time"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Campi"
msgstr "Fields"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Allegati"
msgstr "Attachments"
#~ msgid "Esci"
#~ msgstr "Logout"
@@ -756,6 +756,14 @@ msgstr "Il codice non può essere modificato"
msgid "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
msgid "Nome descrittivo della stazione"
msgstr "Nome descrittivo della stazione"
@@ -2973,6 +2981,36 @@ msgstr "Seleziona un punto di misura per l'istogramma"
msgid "Errore nella generazione del report"
msgstr "Errore nella generazione del report"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Esiti dei task della sessione"
msgstr "Esiti dei task della sessione"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Nessun esito registrato in questa sessione"
msgstr "Nessun esito registrato in questa sessione"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Fatto"
msgstr "Fatto"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Ora"
msgstr "Ora"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Campi"
msgstr "Campi"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Allegati"
msgstr "Allegati"
#~ msgid "Esci"
#~ msgstr "Esci"