Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74fe5665b5 | |||
| be904bac07 | |||
| 011c08c9d8 | |||
| 82ad88538d | |||
| bafc776ef7 | |||
| 8fcb4796d5 | |||
| 8d7fa9a12a | |||
| c911449b7c | |||
| ac8604eff5 | |||
| 631ba4ee05 | |||
| 40eed5c2af | |||
| 275bbd1a5f | |||
| 95d643d6d8 | |||
| 9306fbf653 | |||
| 1a5e98f072 | |||
| fdfcf05951 | |||
| 26884ebf4f |
@@ -47,6 +47,13 @@ WORKDIR /app/flask_app
|
|||||||
RUN uv run --project /app pybabel compile -d translations && \
|
RUN uv run --project /app pybabel compile -d translations && \
|
||||||
chown -R app:app /app /tmp/uv-cache
|
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
|
USER app
|
||||||
|
|
||||||
EXPOSE 5000
|
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 up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
||||||
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
| `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 server` | Segui log server in tempo reale |
|
||||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||||
| `docker compose ps` | Stato di tutti i servizi |
|
| `docker compose ps` | Stato di tutti i servizi |
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
|
args:
|
||||||
|
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||||
|
APP_COMMIT: ${APP_COMMIT:-}
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
|
args:
|
||||||
|
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||||
|
APP_COMMIT: ${APP_COMMIT:-}
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
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.production import ProductionRun
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
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.orm.user import User
|
||||||
from src.backend.models.api.statistics import (
|
from src.backend.models.api.statistics import (
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
@@ -294,3 +295,76 @@ async def get_recipe_runs(
|
|||||||
}
|
}
|
||||||
for r in 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}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
# Idempotent on purpose (#71): a database born from create_all has no
|
||||||
|
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
|
||||||
|
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
|
||||||
|
# created and `alembic_version` still at 014. Every step checks first.
|
||||||
|
insp = sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
if 'api_keys' not in insp.get_table_names():
|
||||||
op.create_table(
|
op.create_table(
|
||||||
'api_keys',
|
'api_keys',
|
||||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
@@ -34,11 +41,24 @@ def upgrade() -> None:
|
|||||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
existing_idx: set[str] = set()
|
||||||
|
else:
|
||||||
|
existing_idx = {i['name'] for i in insp.get_indexes('api_keys')}
|
||||||
|
|
||||||
|
if 'ix_api_keys_key_hash' not in existing_idx:
|
||||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||||
|
if 'ix_api_keys_user_id' not in existing_idx:
|
||||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||||
|
|
||||||
|
if 'api_key' not in {c['name'] for c in insp.get_columns('users')}:
|
||||||
|
return # column already gone: nothing else to do
|
||||||
|
|
||||||
|
user_idx = {i['name'] for i in insp.get_indexes('users')}
|
||||||
|
user_uq = {u['name'] for u in insp.get_unique_constraints('users')}
|
||||||
with op.batch_alter_table('users') as batch:
|
with op.batch_alter_table('users') as batch:
|
||||||
|
if 'ix_users_api_key' in user_idx:
|
||||||
batch.drop_index('ix_users_api_key')
|
batch.drop_index('ix_users_api_key')
|
||||||
|
if 'uq_users_api_key' in user_uq:
|
||||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||||
batch.drop_column('api_key')
|
batch.drop_column('api_key')
|
||||||
|
|
||||||
|
|||||||
@@ -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 src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if not value.lower().startswith(("http://", "https://")):
|
||||||
|
raise ValueError("vision_worker_url must start with http:// or https://")
|
||||||
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
class StationCreate(BaseModel):
|
class StationCreate(BaseModel):
|
||||||
@@ -11,6 +23,10 @@ class StationCreate(BaseModel):
|
|||||||
location: Optional[str] = Field(default=None, max_length=255)
|
location: Optional[str] = Field(default=None, max_length=255)
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
active: bool = True
|
active: bool = True
|
||||||
|
# worker di visione della stazione (#45); vuoto = centrale
|
||||||
|
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||||
|
|
||||||
|
|
||||||
class StationUpdate(BaseModel):
|
class StationUpdate(BaseModel):
|
||||||
@@ -18,6 +34,10 @@ class StationUpdate(BaseModel):
|
|||||||
location: Optional[str] = Field(default=None, max_length=255)
|
location: Optional[str] = Field(default=None, max_length=255)
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
active: Optional[bool] = None
|
active: Optional[bool] = None
|
||||||
|
# worker di visione della stazione (#45); "" -> None riporta al centrale
|
||||||
|
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||||
|
|
||||||
|
|
||||||
class StationResponse(BaseModel):
|
class StationResponse(BaseModel):
|
||||||
@@ -28,6 +48,7 @@ class StationResponse(BaseModel):
|
|||||||
location: Optional[str]
|
location: Optional[str]
|
||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
active: bool
|
active: bool
|
||||||
|
vision_worker_url: Optional[str] = None # #45
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: UtcDateTime
|
created_at: UtcDateTime
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class Station(Base):
|
|||||||
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
# URL del worker di visione della stazione (#45); NULL = worker centrale
|
||||||
|
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, nullable=False, server_default=func.now()
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class VisionResult(Base):
|
|||||||
Integer, ForeignKey("stations.id"), nullable=True
|
Integer, ForeignKey("stations.id"), nullable=True
|
||||||
)
|
)
|
||||||
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
# URL del worker effettivamente chiamato (#45), copiato: la stazione puo' cambiarlo dopo
|
||||||
|
worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
# Copied, not referenced: a calibration redone six months from now must not
|
# Copied, not referenced: a calibration redone six months from now must not
|
||||||
# rewrite what a measurement already taken means. Same for the graph.
|
# rewrite what a measurement already taken means. Same for the graph.
|
||||||
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ async def create_station(
|
|||||||
location=data.location,
|
location=data.location,
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
active=data.active,
|
active=data.active,
|
||||||
|
vision_worker_url=data.vision_worker_url, # #45
|
||||||
created_by=creator.id,
|
created_by=creator.id,
|
||||||
)
|
)
|
||||||
db.add(station)
|
db.add(station)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
from src.backend.models.orm.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
from src.backend.services import measurement_service
|
from src.backend.services import measurement_service
|
||||||
@@ -53,6 +54,7 @@ async def _call_worker(
|
|||||||
*,
|
*,
|
||||||
recipe_dir: str | None = None,
|
recipe_dir: str | None = None,
|
||||||
calibration: dict | None = None,
|
calibration: dict | None = None,
|
||||||
|
worker_url: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""The one place that talks to the worker; the tests replace it.
|
"""The one place that talks to the worker; the tests replace it.
|
||||||
|
|
||||||
@@ -70,6 +72,8 @@ async def _call_worker(
|
|||||||
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
|
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
|
||||||
# si mandano, il worker usa i suoi default.
|
# si mandano, il worker usa i suoi default.
|
||||||
data = {"graph": json.dumps(graph)}
|
data = {"graph": json.dumps(graph)}
|
||||||
|
# worker della stazione (#45) o, se manca, il centrale; mai un ripiego dall'uno all'altro
|
||||||
|
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||||
if recipe_dir:
|
if recipe_dir:
|
||||||
data["recipe_dir"] = recipe_dir
|
data["recipe_dir"] = recipe_dir
|
||||||
if calibration:
|
if calibration:
|
||||||
@@ -77,19 +81,19 @@ async def _call_worker(
|
|||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{settings.vision_worker_url}/run",
|
f"{base}/run",
|
||||||
files={"image": ("frame.png", image_bytes, "image/png")},
|
files={"image": ("frame.png", image_bytes, "image/png")},
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
detail=f"the vision worker did not answer in time: {exc}",
|
detail=f"the vision worker did not answer in time: {exc} ({base})", # #45
|
||||||
) from exc
|
) from exc
|
||||||
except (httpx.TransportError, httpx.HTTPError) as exc:
|
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"the vision worker is unreachable: {exc}",
|
detail=f"the vision worker is unreachable: {exc} ({base})", # #45
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# The worker already gave this fault a name - forward its status and
|
# The worker already gave this fault a name - forward its status and
|
||||||
@@ -352,12 +356,20 @@ async def execute_task(
|
|||||||
detail="this recipe is not active",
|
detail="this recipe is not active",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
run = None
|
||||||
if production_run_id is not None:
|
if production_run_id is not None:
|
||||||
# 404 if the run does not exist, 422 if it belongs to another recipe,
|
# 404 if the run does not exist, 422 if it belongs to another recipe,
|
||||||
# 409 if it is closed or paused - same rule a manual reading follows.
|
# 409 if it is closed or paused - same rule a manual reading follows.
|
||||||
await measurement_service.validate_run_for_version(
|
run = await measurement_service.validate_run_for_version(
|
||||||
db, production_run_id, version_id,
|
db, production_run_id, version_id,
|
||||||
)
|
)
|
||||||
|
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||||
|
station_url = None
|
||||||
|
if run is not None:
|
||||||
|
station = (await db.execute(
|
||||||
|
select(Station).where(Station.id == run.station_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||||
|
|
||||||
if not task.vision_json:
|
if not task.vision_json:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -411,6 +423,7 @@ async def execute_task(
|
|||||||
# passa `calibration`, la ricetta porta la sua (#46).
|
# passa `calibration`, la ricetta porta la sua (#46).
|
||||||
report = await _call_worker(
|
report = await _call_worker(
|
||||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
|
worker_url=station_url, # #45
|
||||||
)
|
)
|
||||||
outputs = report["outputs"]
|
outputs = report["outputs"]
|
||||||
|
|
||||||
@@ -439,7 +452,10 @@ async def execute_task(
|
|||||||
image_path=image_path,
|
image_path=image_path,
|
||||||
overlay_path=overlay_path,
|
overlay_path=overlay_path,
|
||||||
engine_version=report["engine_version"],
|
engine_version=report["engine_version"],
|
||||||
executed_on="server",
|
# #45: dove e' girato davvero, e quale worker (copiato come lo snapshot)
|
||||||
|
executed_on="station" if station_url else "server",
|
||||||
|
station_id=run.station_id if run is not None else None,
|
||||||
|
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||||
graph_snapshot=task.vision_json,
|
graph_snapshot=task.vision_json,
|
||||||
duration_ms=report.get("duration_ms"),
|
duration_ms=report.get("duration_ms"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from alembic import command
|
from alembic import command
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
||||||
|
|
||||||
from src.backend.config import Settings
|
from src.backend.config import Settings
|
||||||
@@ -132,6 +135,70 @@ class TestApiKeyHashMigration:
|
|||||||
command.upgrade(cfg, "head") # and up again
|
command.upgrade(cfg, "head") # and up again
|
||||||
assert "api_keys" in _inspect(db_path).get_table_names()
|
assert "api_keys" in _inspect(db_path).get_table_names()
|
||||||
|
|
||||||
|
def test_015_on_create_all_schema(self, sqlite_database_url):
|
||||||
|
"""DB nato da create_all: users.api_key ha solo l'indice unique
|
||||||
|
ix_users_api_key, nessun vincolo uq_users_api_key (#71)."""
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ctx = MigrationContext.configure(conn)
|
||||||
|
ops = Operations(ctx)
|
||||||
|
with ops.batch_alter_table("users") as batch:
|
||||||
|
batch.drop_index("ix_users_api_key")
|
||||||
|
batch.drop_constraint("uq_users_api_key", type_="unique")
|
||||||
|
ops.create_index("ix_users_api_key", "users", ["api_key"], unique=True)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert not insp.get_unique_constraints("users") or "uq_users_api_key" not in {
|
||||||
|
u["name"] for u in insp.get_unique_constraints("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head") # non deve sollevare
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "api_keys" in insp.get_table_names()
|
||||||
|
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||||
|
|
||||||
|
def test_015_resumes_after_partial_run(self, sqlite_database_url):
|
||||||
|
"""015 a meta' (MySQL, DDL non transazionale): api_keys creata e
|
||||||
|
ix_users_api_key tolto, alembic_version ancora 014 (#71)."""
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ops = Operations(MigrationContext.configure(conn))
|
||||||
|
ops.create_table(
|
||||||
|
"api_keys",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("key_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"], ["users.id"],
|
||||||
|
name="fk_api_keys_user_id", ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ops.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
|
||||||
|
ops.drop_index("ix_users_api_key", table_name="users")
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head") # non deve sollevare
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "api_keys" in insp.get_table_names()
|
||||||
|
assert {i["name"] for i in insp.get_indexes("api_keys")} >= {
|
||||||
|
"ix_api_keys_key_hash", "ix_api_keys_user_id",
|
||||||
|
}
|
||||||
|
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||||
|
|
||||||
|
|
||||||
class TestVisionRecipeDirMigration:
|
class TestVisionRecipeDirMigration:
|
||||||
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
||||||
@@ -233,3 +300,24 @@ class TestTaskLoopResultsMigration:
|
|||||||
|
|
||||||
with pytest.raises(RuntimeError, match="check"):
|
with pytest.raises(RuntimeError, match="check"):
|
||||||
command.downgrade(cfg, "017_user_pin")
|
command.downgrade(cfg, "017_user_pin")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStationVisionWorkerMigration:
|
||||||
|
"""019_station_vision_worker: stations.vision_worker_url e vision_results.worker_url, su e giu (#45)."""
|
||||||
|
|
||||||
|
def test_019_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols(table):
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert "vision_worker_url" in cols("stations")
|
||||||
|
assert "worker_url" in cols("vision_results")
|
||||||
|
command.downgrade(cfg, "018_task_loop_results")
|
||||||
|
assert "vision_worker_url" not in cols("stations")
|
||||||
|
assert "worker_url" not in cols("vision_results")
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert "vision_worker_url" in cols("stations")
|
||||||
|
assert "worker_url" in cols("vision_results")
|
||||||
|
|||||||
@@ -374,3 +374,59 @@ async def test_reset_unknown_station_is_404(client: AsyncClient, admin_user):
|
|||||||
"/api/stations/999999/recipes", headers=auth_headers(admin_user),
|
"/api/stations/999999/recipes", headers=auth_headers(admin_user),
|
||||||
)
|
)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# --- vision_worker_url (#45) ---
|
||||||
|
|
||||||
|
async def test_station_vision_worker_url_roundtrip(client: AsyncClient, admin_user):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/stations",
|
||||||
|
headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-VW", "name": "VW", "vision_worker_url": " http://10.0.0.7:8100/ "},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
# spazi tolti e "/" finale tolto
|
||||||
|
assert created.json()["vision_worker_url"] == "http://10.0.0.7:8100"
|
||||||
|
|
||||||
|
sid = created.json()["id"]
|
||||||
|
updated = await client.put(
|
||||||
|
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||||
|
json={"vision_worker_url": "https://w.example:9000"},
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["vision_worker_url"] == "https://w.example:9000"
|
||||||
|
|
||||||
|
# stringa vuota = torna al worker centrale
|
||||||
|
cleared = await client.put(
|
||||||
|
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||||
|
json={"vision_worker_url": ""},
|
||||||
|
)
|
||||||
|
assert cleared.status_code == 200
|
||||||
|
assert cleared.json()["vision_worker_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_vision_worker_url_defaults_to_null(client: AsyncClient, admin_user):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-VN", "name": "VN"},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
assert created.json()["vision_worker_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin_user):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-VF", "name": "VF", "vision_worker_url": "ftp://x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
created = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-VG", "name": "VG"},
|
||||||
|
)
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
|
||||||
|
json={"vision_worker_url": "ftp://x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|||||||
@@ -287,3 +287,116 @@ class TestRunFilter:
|
|||||||
headers=auth_headers(measurement_tec_user),
|
headers=auth_headers(measurement_tec_user),
|
||||||
params={"recipe_id": 1})
|
params={"recipe_id": 1})
|
||||||
assert resp.status_code == 403
|
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"),
|
"company_logo": session.get("company_logo"),
|
||||||
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
||||||
"current_year": date.today().year,
|
"current_year": date.today().year,
|
||||||
|
# Versione/commit nel piede di pagina (#70)
|
||||||
|
"app_version": Config.APP_VERSION,
|
||||||
|
"app_commit": Config.APP_COMMIT,
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
|
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
|
||||||
import requests as http_requests
|
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 flask_babel import gettext as _
|
||||||
|
|
||||||
from blueprints.auth import login_required, role_required
|
from blueprints.auth import login_required, role_required
|
||||||
from config import Config
|
from config import Config
|
||||||
from services.api_client import api_client
|
from services.api_client import api_client
|
||||||
|
from services.file_proxy import proxy_file # #58: allegati dei task
|
||||||
|
|
||||||
statistics_bp = Blueprint("statistics", __name__)
|
statistics_bp = Blueprint("statistics", __name__)
|
||||||
|
|
||||||
@@ -148,6 +149,33 @@ def api_runs():
|
|||||||
return jsonify(resp)
|
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
|
# PROXY REPORT PDF → FastAPI
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""TieMeasureFlow Client Configuration."""
|
"""TieMeasureFlow Client Configuration."""
|
||||||
import os
|
import os
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
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"
|
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:
|
class Config:
|
||||||
"""Flask client configuration."""
|
"""Flask client configuration."""
|
||||||
|
|
||||||
@@ -17,6 +38,12 @@ class Config:
|
|||||||
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
|
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
|
||||||
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
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 connection
|
||||||
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,17 @@
|
|||||||
maxlength="255">
|
maxlength="255">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Worker visione (#45): vuoto = worker centrale #}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Worker visione (URL)') }}</label>
|
||||||
|
<input type="text" x-model="form.vision_worker_url"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||||
|
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||||
|
placeholder="host:8100"
|
||||||
|
maxlength="500">
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
|
||||||
<textarea x-model="form.notes" rows="3"
|
<textarea x-model="form.notes" rows="3"
|
||||||
@@ -452,6 +463,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
name: '',
|
name: '',
|
||||||
location: '',
|
location: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
vision_worker_url: '', // #45
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -505,7 +517,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
this.isEditing = false;
|
this.isEditing = false;
|
||||||
this.editingId = null;
|
this.editingId = null;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
this.form = { code: '', name: '', location: '', notes: '', active: true };
|
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', active: true };
|
||||||
this.showModal = true;
|
this.showModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -518,6 +530,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
name: station.name,
|
name: station.name,
|
||||||
location: station.location || '',
|
location: station.location || '',
|
||||||
notes: station.notes || '',
|
notes: station.notes || '',
|
||||||
|
vision_worker_url: station.vision_worker_url || '', // #45
|
||||||
active: station.active,
|
active: station.active,
|
||||||
};
|
};
|
||||||
this.showModal = true;
|
this.showModal = true;
|
||||||
@@ -537,6 +550,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
name: this.form.name,
|
name: this.form.name,
|
||||||
location: this.form.location || null,
|
location: this.form.location || null,
|
||||||
notes: this.form.notes || null,
|
notes: this.form.notes || null,
|
||||||
|
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
||||||
active: this.form.active,
|
active: this.form.active,
|
||||||
};
|
};
|
||||||
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
|
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
|
||||||
@@ -563,6 +577,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
name: this.form.name,
|
name: this.form.name,
|
||||||
location: this.form.location || null,
|
location: this.form.location || null,
|
||||||
notes: this.form.notes || null,
|
notes: this.form.notes || null,
|
||||||
|
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
||||||
active: this.form.active,
|
active: this.form.active,
|
||||||
};
|
};
|
||||||
const resp = await fetch('/admin/api/stations', {
|
const resp = await fetch('/admin/api/stations', {
|
||||||
|
|||||||
@@ -157,7 +157,7 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="text-center text-xs text-[var(--text-muted)]">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
|
<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">
|
<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>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -129,6 +129,74 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Nessuna ricetta selezionata -->
|
||||||
<template x-if="!selectedRecipeId && !loading">
|
<template x-if="!selectedRecipeId && !loading">
|
||||||
<div class="text-center py-16 text-steel">
|
<div class="text-center py-16 text-steel">
|
||||||
@@ -290,8 +358,52 @@ function spcDashboard() {
|
|||||||
reportError: '',
|
reportError: '',
|
||||||
downloadingReport: false,
|
downloadingReport: false,
|
||||||
|
|
||||||
|
// #58: esiti dei task della sessione scelta
|
||||||
|
taskResults: null,
|
||||||
|
taskResultsLoading: false,
|
||||||
|
taskResultsError: '',
|
||||||
|
|
||||||
init() {
|
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() {
|
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")
|
resp = logged_in_client.get("/admin/stations")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"REC-ABC" in resp.data
|
assert b"REC-ABC" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
# --- Worker visione per stazione (#45) ---
|
||||||
|
|
||||||
|
def test_station_form_has_the_vision_worker_field(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.get.return_value = []
|
||||||
|
resp = logged_in_client.get("/admin/stations")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"form.vision_worker_url" in resp.data
|
||||||
|
# etichetta in italiano o in inglese, secondo il catalogo compilato
|
||||||
|
assert b"Worker visione (URL)" in resp.data or b"Vision worker (URL)" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_list_exposes_the_vision_worker_url(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.get.side_effect = [
|
||||||
|
[{"id": 1, "code": "ST-001", "name": "Linea A", "location": None,
|
||||||
|
"notes": None, "active": True, "vision_worker_url": "http://10.0.0.7:8100",
|
||||||
|
"created_by": 1, "created_at": "2026-04-25T10:00:00"}],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
resp = logged_in_client.get("/admin/stations")
|
||||||
|
assert b"http://10.0.0.7:8100" in resp.data # nel JSON iniziale della pagina
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_proxies_pass_the_vision_worker_url_untouched(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.post.return_value = {"id": 7, "code": "ST-7", "name": "n"}
|
||||||
|
mock_admin_api.put.return_value = {"id": 7, "code": "ST-7", "name": "n"}
|
||||||
|
logged_in_client.post(
|
||||||
|
"/admin/api/stations",
|
||||||
|
json={"code": "ST-7", "name": "n", "vision_worker_url": "http://w:8100"},
|
||||||
|
)
|
||||||
|
mock_admin_api.post.assert_called_once_with(
|
||||||
|
"/api/stations",
|
||||||
|
data={"code": "ST-7", "name": "n", "vision_worker_url": "http://w:8100"},
|
||||||
|
)
|
||||||
|
logged_in_client.put("/admin/api/stations/7", json={"vision_worker_url": None})
|
||||||
|
mock_admin_api.put.assert_called_once_with(
|
||||||
|
"/api/stations/7", data={"vision_worker_url": None},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}
|
mock_api_client.get.return_value = {"total": 1}
|
||||||
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
|
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"
|
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"
|
msgid "Identificativo univoco usato dal client tramite STATION_CODE"
|
||||||
msgstr "Unique identifier used by client via STATION_CODE"
|
msgstr "Unique identifier used by client via STATION_CODE"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html
|
||||||
|
msgid "Worker visione (URL)"
|
||||||
|
msgstr "Vision worker (URL)"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html
|
||||||
|
msgid "Vuoto = worker centrale. Indirizzo completo, con http o https"
|
||||||
|
msgstr "Empty = central worker. Full address, with http or https"
|
||||||
|
|
||||||
#: templates/admin/stations.html:194
|
#: templates/admin/stations.html:194
|
||||||
msgid "Nome descrittivo della stazione"
|
msgid "Nome descrittivo della stazione"
|
||||||
msgstr "Descriptive name of the station"
|
msgstr "Descriptive name of the station"
|
||||||
@@ -2969,6 +2977,36 @@ msgstr "Select a measurement point for the histogram"
|
|||||||
msgid "Errore nella generazione del report"
|
msgid "Errore nella generazione del report"
|
||||||
msgstr "Error generating 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"
|
#~ msgid "Esci"
|
||||||
#~ msgstr "Logout"
|
#~ msgstr "Logout"
|
||||||
|
|
||||||
|
|||||||
@@ -756,6 +756,14 @@ msgstr "Il codice non può essere modificato"
|
|||||||
msgid "Identificativo univoco usato dal client tramite STATION_CODE"
|
msgid "Identificativo univoco usato dal client tramite STATION_CODE"
|
||||||
msgstr "Identificativo univoco usato dal client tramite STATION_CODE"
|
msgstr "Identificativo univoco usato dal client tramite STATION_CODE"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html
|
||||||
|
msgid "Worker visione (URL)"
|
||||||
|
msgstr "Worker visione (URL)"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html
|
||||||
|
msgid "Vuoto = worker centrale. Indirizzo completo, con http o https"
|
||||||
|
msgstr "Vuoto = worker centrale. Indirizzo completo, con http o https"
|
||||||
|
|
||||||
#: templates/admin/stations.html:194
|
#: templates/admin/stations.html:194
|
||||||
msgid "Nome descrittivo della stazione"
|
msgid "Nome descrittivo della stazione"
|
||||||
msgstr "Nome descrittivo della stazione"
|
msgstr "Nome descrittivo della stazione"
|
||||||
@@ -2973,6 +2981,36 @@ msgstr "Seleziona un punto di misura per l'istogramma"
|
|||||||
msgid "Errore nella generazione del report"
|
msgid "Errore nella generazione del report"
|
||||||
msgstr "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"
|
#~ msgid "Esci"
|
||||||
#~ msgstr "Esci"
|
#~ msgstr "Esci"
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
Submodule vendor/visionsuite updated: 1e45b33222...cdc7a14695
Reference in New Issue
Block a user