Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 011c08c9d8 | |||
| 82ad88538d | |||
| bafc776ef7 | |||
| 8fcb4796d5 | |||
| 8d7fa9a12a | |||
| c911449b7c | |||
| ac8604eff5 | |||
| 631ba4ee05 | |||
| 40eed5c2af | |||
| 275bbd1a5f | |||
| 95d643d6d8 | |||
| 9306fbf653 |
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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 © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
|
||||
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}{# versione e commit (#70) #} · v{{ app_version }}{% if app_commit %} · {{ app_commit }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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> — Tielogic
|
||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic{# versione e commit (#70) #} · v{{ app_version }}{% if app_commit %} · {{ 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()} ·" 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"
|
||||
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/visionsuite updated: adf7a2640d...cdc7a14695
Reference in New Issue
Block a user