Compare commits

..

12 Commits

Author SHA1 Message Date
Adriano 1453b31cee by-code: auth da utente loggato qualunque, come /recipes (#72)
Lo chiama il proxy Flask con la chiave dell'operatore MeasurementTec, quindi
get_current_user al posto di require_admin_user. Test: MeasurementTec 200,
senza chiave 401.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:53:33 +02:00
Adriano b9f43a0ede Stazioni: camera_url e camera_source, GET /api/stations/by-code/{code} (#72)
Migrazione 020 con le due colonne su stations, campi in modello, schemi e
servizio (validatore http(s) come vision_worker_url), nuova route by-code
dichiarata prima di /{station_id}. Test di migrazione su e giu e di API.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:50:14 +02:00
Adriano 5bf374aadd Merge lavoro/jeeg-tmf71: 015_api_key_hash idempotente (Fixes #71)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 09:28:40 +02:00
Adriano 74fe5665b5 015_api_key_hash idempotente: DB da create_all e 015 a metà su MySQL (#71)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:25:07 +02:00
Adriano be904bac07 Merge lavoro/daitarn-tmf73: vendor visionsuite cdc7a14 (Fixes #73)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcYsFQDaTPnDVjrQkWj8R
2026-10-13 09:26:04 +02:00
Adriano 011c08c9d8 vendor visionsuite adf7a26 -> cdc7a14: revisione GUI e assistente (visionsuite#305-#316) (#73)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcYsFQDaTPnDVjrQkWj8R
2026-10-13 09:26:00 +02:00
Adriano 82ad88538d Merge lavoro/daitarn-70: versione e commit nel piè di pagina (Fixes #70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019u7uKckaZ7DBe4oGbiwuHa
2026-10-12 14:25:04 +02:00
Adriano bafc776ef7 Versione e commit nel piè di pagina (#70)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:22:02 +02:00
Adriano 8fcb4796d5 Merge lavoro/daitarn-58: esiti, note, campi e allegati dei task nella statistica per sessione (Fixes #58)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:22:39 +00:00
Adriano 8d7fa9a12a Statistica: pannello esiti, note, campi e allegati dei task per sessione, proxy e traduzioni (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:08:18 +00:00
Adriano c911449b7c Statistica: endpoint esiti, note, campi e allegati dei task di una sessione (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:07:06 +00:00
Adriano ac8604eff5 Merge lavoro/daitarn-45: worker di visione configurabile per stazione (Fixes #45)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:03:57 +00:00
25 changed files with 818 additions and 24 deletions
+7
View File
@@ -47,6 +47,13 @@ WORKDIR /app/flask_app
RUN uv run --project /app pybabel compile -d translations && \
chown -R app:app /app /tmp/uv-cache
# Commit mostrato nel piede di pagina (#70). .git e' escluso dal contesto di
# build (.dockerignore), quindi lo passa chi costruisce; facoltativo, senza il
# piede mostra solo la versione. Uso:
# APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client
ARG APP_COMMIT=""
ENV APP_COMMIT=${APP_COMMIT}
USER app
EXPOSE 5000
+1
View File
@@ -333,6 +333,7 @@ TieMeasureFlow/
|---|---|
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
| `APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client` | Ricostruisci il client mostrando nel piè di pagina il commit (oltre alla versione di `pyproject.toml`); senza `APP_COMMIT` compare solo la versione |
| `docker compose logs -f server` | Segui log server in tempo reale |
| `docker compose logs -f client` | Segui log client in tempo reale |
| `docker compose ps` | Stato di tutti i servizi |
+3
View File
@@ -75,6 +75,9 @@ services:
build:
context: .
dockerfile: Dockerfile.frontend
args:
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
APP_COMMIT: ${APP_COMMIT:-}
container_name: tmflow-client
restart: unless-stopped
env_file:
+3
View File
@@ -89,6 +89,9 @@ services:
build:
context: .
dockerfile: Dockerfile.frontend
args:
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
APP_COMMIT: ${APP_COMMIT:-}
container_name: tmflow-client
restart: unless-stopped
env_file:
+15
View File
@@ -67,6 +67,21 @@ async def create_new_station(
# The int-typed station_id param already guards against "by-code" being
# matched as a station id, but keeping the explicit order avoids surprises
# during refactors (e.g. if someone regroups handlers by HTTP method).
@router.get("/by-code/{code}", response_model=StationResponse)
async def get_station_by_code(
code: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get a station by its code (any authenticated user, like /by-code/{code}/recipes) (#72)."""
station = await station_service.get_station_by_code(db, code)
if station is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Station not found",
)
return StationResponse.model_validate(station)
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
async def list_recipes_by_station_code(
code: str,
+74
View File
@@ -12,6 +12,7 @@ from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
from src.backend.models.orm.user import User
from src.backend.models.api.statistics import (
CapabilityData,
@@ -294,3 +295,76 @@ async def get_recipe_runs(
}
for r in runs
]
@router.get("/runs/{run_id}/task-results")
async def get_run_task_results(
run_id: int,
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> dict:
"""#58: esiti, note, campi e allegati dei task di una sessione di produzione.
Endpoint di statistica (require_metrologist): GET /api/task-results vuole
il ruolo MeasurementTec e non si puo' riusare qui.
"""
run = (await db.execute(
select(ProductionRun.id).where(ProductionRun.id == run_id)
)).scalar_one_or_none()
if run is None:
raise HTTPException(status_code=404, detail="Sessione non trovata")
# #58: una query per i risultati della run, una per i task, una per gli utenti.
results = (await db.execute(
select(TaskResult)
.where(TaskResult.production_run_id == run_id)
.order_by(TaskResult.created_at.asc(), TaskResult.id.asc())
)).scalars().all()
task_ids = {r.task_id for r in results}
tasks = {}
if task_ids:
tasks = {t.id: t for t in (await db.execute(
select(RecipeTask).where(RecipeTask.id.in_(task_ids))
)).scalars().all()}
user_ids = {r.user_id for r in results}
names = {}
if user_ids:
names = dict((await db.execute(
select(User.id, User.display_name).where(User.id.in_(user_ids))
)).all())
counts = {"ok": 0, "nok": 0, "done": 0}
out = []
for r in results:
counts[r.outcome] = counts.get(r.outcome, 0) + 1
task = tasks.get(r.task_id)
# #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave.
labels = {
f.get("key"): f.get("label") or f.get("key")
for f in (task.fields or []) if isinstance(f, dict)
} if task else {}
out.append({
"id": r.id,
"task_id": r.task_id,
"task_title": task.title if task else None,
"task_type": task.task_type if task else None,
"outcome": r.outcome,
"note": r.note,
"created_at": r.created_at.isoformat() if r.created_at else None,
"user_name": names.get(r.user_id),
"values": [
{"key": k, "label": labels.get(k, k), "value": v}
for k, v in (r.values or {}).items()
],
"attachments": [
{
"key": a.get("key"),
"label": labels.get(a.get("key"), a.get("key")),
"name": a.get("name"),
"path": a.get("path"),
}
for a in (r.attachments or []) if isinstance(a, dict)
],
})
return {"run_id": run_id, "counts": counts, "results": out}
@@ -22,24 +22,44 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.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'
),
)
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
# 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(
'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'
),
)
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)
if 'ix_api_keys_user_id' not in existing_idx:
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:
batch.drop_index('ix_users_api_key')
batch.drop_constraint('uq_users_api_key', type_='unique')
if 'ix_users_api_key' in user_idx:
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_column('api_key')
@@ -0,0 +1,33 @@
"""stations.camera_url e stations.camera_source (#72)
stations.camera_url: servizio camera della stazione (vs-gui o equivalente).
stations.camera_source: sorgente da chiedere al servizio, es. camera:<seriale>
o camera:folder.
Revision ID: 020_station_camera
Revises: 019_station_vision_worker
Create Date: 2026-10-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '020_station_camera'
down_revision: Union[str, None] = '019_station_vision_worker'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('stations') as batch:
batch.add_column(sa.Column('camera_url', sa.String(500), nullable=True))
batch.add_column(sa.Column('camera_source', sa.String(200), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('stations') as batch:
batch.drop_column('camera_source')
batch.drop_column('camera_url')
+32 -3
View File
@@ -5,18 +5,35 @@ from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
def _normalize_http_url(value: Optional[str], field: str) -> Optional[str]:
"""Vuoto/spazi -> None; altrimenti http(s) senza '/' finale."""
if value is None:
return None
value = value.strip()
if not value:
return None
if not value.lower().startswith(("http://", "https://")):
raise ValueError("vision_worker_url must start with http:// or https://")
raise ValueError(f"{field} must start with http:// or https://")
return value.rstrip("/")
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
return _normalize_http_url(value, "vision_worker_url")
def _normalize_camera_url(value: Optional[str]) -> Optional[str]:
"""Vuoto/spazi -> None (nessuna camera); altrimenti http(s) senza '/' finale (#72)."""
return _normalize_http_url(value, "camera_url")
def _normalize_camera_source(value: Optional[str]) -> Optional[str]:
"""Strip; vuoto -> None (#72)."""
if value is None:
return None
return value.strip() or None
class StationCreate(BaseModel):
code: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
@@ -25,8 +42,13 @@ class StationCreate(BaseModel):
active: bool = True
# worker di visione della stazione (#45); vuoto = centrale
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
# camera della stazione (#72); vuoto = nessuna camera
camera_url: Optional[str] = Field(default=None, max_length=500)
camera_source: Optional[str] = Field(default=None, max_length=200)
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
class StationUpdate(BaseModel):
@@ -36,8 +58,13 @@ class StationUpdate(BaseModel):
active: Optional[bool] = None
# worker di visione della stazione (#45); "" -> None riporta al centrale
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
# camera della stazione (#72); vuoto = nessuna camera
camera_url: Optional[str] = Field(default=None, max_length=500)
camera_source: Optional[str] = Field(default=None, max_length=200)
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
class StationResponse(BaseModel):
@@ -49,6 +76,8 @@ class StationResponse(BaseModel):
notes: Optional[str]
active: bool
vision_worker_url: Optional[str] = None # #45
camera_url: Optional[str] = None # #72
camera_source: Optional[str] = None # #72
created_by: int
created_at: UtcDateTime
+3
View File
@@ -27,6 +27,9 @@ class Station(Base):
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
# URL del worker di visione della stazione (#45); NULL = worker centrale
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# camera della stazione (#72): URL del servizio camera e sorgente da chiedergli
camera_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
camera_source: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
+2
View File
@@ -32,6 +32,8 @@ async def create_station(
notes=data.notes,
active=data.active,
vision_worker_url=data.vision_worker_url, # #45
camera_url=data.camera_url, # #72
camera_source=data.camera_source, # #72
created_by=creator.id,
)
db.add(station)
+87
View File
@@ -13,7 +13,10 @@ from pathlib import Path
import pytest
from alembic import command
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from alembic.config import Config
import sqlalchemy as sa
from sqlalchemy import create_engine, inspect as sa_inspect, text
from src.backend.config import Settings
@@ -132,6 +135,70 @@ class TestApiKeyHashMigration:
command.upgrade(cfg, "head") # and up again
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:
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
@@ -254,3 +321,23 @@ class TestStationVisionWorkerMigration:
command.upgrade(cfg, "head") # e di nuovo su
assert "vision_worker_url" in cols("stations")
assert "worker_url" in cols("vision_results")
class TestStationCameraMigration:
"""020_station_camera: stations.camera_url e stations.camera_source, su e giu (#72)."""
def test_020_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def cols(table):
return {c["name"] for c in _inspect(db_path).get_columns(table)}
command.upgrade(cfg, "head")
assert {"camera_url", "camera_source"} <= cols("stations")
command.downgrade(cfg, "019_station_vision_worker")
assert "camera_url" not in cols("stations")
assert "camera_source" not in cols("stations")
assert "vision_worker_url" in cols("stations") # la 019 resta
command.upgrade(cfg, "head") # e di nuovo su
assert {"camera_url", "camera_source"} <= cols("stations")
+100
View File
@@ -430,3 +430,103 @@ async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin
json={"vision_worker_url": "ftp://x"},
)
assert resp.status_code == 422
# --- camera_url / camera_source (#72) ---
async def test_station_camera_fields_roundtrip(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={
"code": "ST-CAM", "name": "CAM",
"camera_url": " http://127.0.0.1:8765/ ",
"camera_source": " camera:folder ",
},
)
assert created.status_code == 201, created.text
assert created.json()["camera_url"] == "http://127.0.0.1:8765"
assert created.json()["camera_source"] == "camera:folder"
sid = created.json()["id"]
read = await client.get(f"/api/stations/{sid}", headers=auth_headers(admin_user))
assert read.status_code == 200
assert read.json()["camera_url"] == "http://127.0.0.1:8765"
assert read.json()["camera_source"] == "camera:folder"
updated = await client.put(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
json={"camera_url": "https://cam.example:9000", "camera_source": "camera:ABC123"},
)
assert updated.status_code == 200
assert updated.json()["camera_url"] == "https://cam.example:9000"
assert updated.json()["camera_source"] == "camera:ABC123"
cleared = await client.put(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
json={"camera_url": "", "camera_source": " "},
)
assert cleared.status_code == 200
assert cleared.json()["camera_url"] is None
assert cleared.json()["camera_source"] is None
async def test_station_camera_fields_default_to_null(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-CN", "name": "CN"},
)
assert created.status_code == 201
assert created.json()["camera_url"] is None
assert created.json()["camera_source"] is None
async def test_station_camera_url_must_be_http(client: AsyncClient, admin_user):
resp = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-CF", "name": "CF", "camera_url": "ftp://x"},
)
assert resp.status_code == 422
created = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-CG", "name": "CG"},
)
resp = await client.put(
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
json={"camera_url": "ftp://x"},
)
assert resp.status_code == 422
async def test_station_by_code_returns_station(client: AsyncClient, admin_user):
await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-BC", "name": "BC", "camera_url": "http://h:1", "camera_source": "camera:folder"},
)
resp = await client.get("/api/stations/by-code/ST-BC", headers=auth_headers(admin_user))
assert resp.status_code == 200, resp.text
assert resp.json()["code"] == "ST-BC"
assert resp.json()["camera_url"] == "http://h:1"
assert resp.json()["camera_source"] == "camera:folder"
async def test_station_by_code_unknown_is_404(client: AsyncClient, admin_user):
resp = await client.get("/api/stations/by-code/NOPE", headers=auth_headers(admin_user))
assert resp.status_code == 404
async def test_station_by_code_any_authenticated_user(
client: AsyncClient, measurement_tec_user, admin_user,
):
await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-BA", "name": "BA"},
)
resp = await client.get("/api/stations/by-code/ST-BA", headers=auth_headers(measurement_tec_user))
assert resp.status_code == 200, resp.text
assert resp.json()["code"] == "ST-BA"
async def test_station_by_code_requires_auth(client: AsyncClient):
resp = await client.get("/api/stations/by-code/ST-BA")
assert resp.status_code == 401
+113
View File
@@ -287,3 +287,116 @@ class TestRunFilter:
headers=auth_headers(measurement_tec_user),
params={"recipe_id": 1})
assert resp.status_code == 403
class TestRunTaskResults:
"""#58: GET /api/statistics/runs/{id}/task-results."""
FIELDS = [
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
]
async def _setup(self, client, db_session, maker_user, tec_user):
from datetime import datetime
from sqlalchemy import select
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task_result import TaskResult
recipe = await create_test_recipe(db_session, maker_user.id, code="TR58")
version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().first()
db_session.add(Station(code="ST-TR58", name="S", active=True, created_by=maker_user.id))
await db_session.commit()
station = (await db_session.execute(select(Station))).scalars().first()
run = ProductionRun(
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
operator_id=tec_user.id, status="open", started_at=datetime(2026, 1, 1),
)
db_session.add(run)
await db_session.commit()
h = auth_headers(maker_user)
check = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=h,
json={"title": "Controllo", "task_type": "check"},
)).json()
note = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=h,
json={"title": "Nota", "task_type": "note", "fields": self.FIELDS},
)).json()
# Inseriti fuori ordine: l'API deve ordinare per created_at.
rows = [
TaskResult(task_id=note["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="done", note="ok finito",
values={"peso": "12", "foto": "x.jpg"},
attachments=[{"key": "foto", "path": "task_results/a.jpg", "name": "a.jpg"}],
created_at=datetime(2026, 1, 1, 10, 3)),
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="nok", note="graffio",
created_at=datetime(2026, 1, 1, 10, 2)),
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="ok",
created_at=datetime(2026, 1, 1, 10, 1)),
# Un esito di un'altra sessione (nessuna) non deve comparire.
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=None,
user_id=tec_user.id, outcome="ok",
created_at=datetime(2026, 1, 1, 9, 0)),
]
db_session.add_all(rows)
await db_session.commit()
return recipe, run, check, note
async def test_counts_order_labels_attachments(
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
):
_, run, check, note = await self._setup(
client, db_session, maker_user, measurement_tec_user)
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
headers=auth_headers(metrologist_user))
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["run_id"] == run.id
assert data["counts"] == {"ok": 1, "nok": 1, "done": 1}
res = data["results"]
assert [r["outcome"] for r in res] == ["ok", "nok", "done"] # per ora crescente
assert res[1]["note"] == "graffio"
assert res[1]["task_title"] == "Controllo"
assert res[1]["task_type"] == "check"
assert res[1]["user_name"] == measurement_tec_user.display_name
assert res[1]["values"] == [] and res[1]["attachments"] == []
n = res[2]
assert n["task_title"] == "Nota" and n["note"] == "ok finito"
assert n["values"] == [
{"key": "peso", "label": "Peso", "value": "12"},
{"key": "foto", "label": "Foto", "value": "x.jpg"},
]
assert n["attachments"] == [
{"key": "foto", "label": "Foto", "name": "a.jpg", "path": "task_results/a.jpg"},
]
async def test_label_falls_back_to_key_when_field_removed(
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
):
from src.backend.models.orm.task import RecipeTask
_, run, _, note = await self._setup(
client, db_session, maker_user, measurement_tec_user)
task = await db_session.get(RecipeTask, note["id"])
task.fields = [self.FIELDS[0]] # il campo «foto» viene tolto dal task
await db_session.commit()
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
headers=auth_headers(metrologist_user))
n = resp.json()["results"][2]
assert {v["key"]: v["label"] for v in n["values"]} == {"peso": "Peso", "foto": "foto"}
assert n["attachments"][0]["label"] == "foto"
async def test_unknown_run_is_404(self, client, metrologist_user):
resp = await client.get("/api/statistics/runs/99999/task-results",
headers=auth_headers(metrologist_user))
assert resp.status_code == 404
async def test_measurement_tec_is_403(self, client, measurement_tec_user):
resp = await client.get("/api/statistics/runs/1/task-results",
headers=auth_headers(measurement_tec_user))
assert resp.status_code == 403
+3
View File
@@ -242,6 +242,9 @@ def create_app() -> Flask:
"company_logo": session.get("company_logo"),
"auto_logout_minutes": session.get("auto_logout_minutes"),
"current_year": date.today().year,
# Versione/commit nel piede di pagina (#70)
"app_version": Config.APP_VERSION,
"app_commit": Config.APP_COMMIT,
}
return app
@@ -1,12 +1,13 @@
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
import requests as http_requests
from flask import Blueprint, jsonify, render_template, request, session
from flask import Blueprint, abort, jsonify, render_template, request, session
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
from config import Config
from services.api_client import api_client
from services.file_proxy import proxy_file # #58: allegati dei task
statistics_bp = Blueprint("statistics", __name__)
@@ -148,6 +149,33 @@ def api_runs():
return jsonify(resp)
@statistics_bp.route("/api/runs/<int:run_id>/task-results")
@login_required
@role_required("Metrologist")
def api_run_task_results(run_id: int):
"""Proxy: esiti, note, campi e allegati dei task di una sessione (#58)."""
resp = api_client.get(f"/api/statistics/runs/{run_id}/task-results")
if isinstance(resp, dict) and resp.get("error"):
error_body = {"error": True, "detail": resp.get("detail", "")}
if resp.get("session_expired"):
error_body["session_expired"] = True
return jsonify(error_body), resp.get("status_code", 500)
return jsonify(resp)
@statistics_bp.route("/api/files/<path:file_path>")
@login_required
@role_required("Metrologist")
def api_task_result_file(file_path: str):
"""Proxy: allegati dei task (#58). Solo task_results/, mai altri file."""
# #58: il Metrologist non deve poter leggere altro dell'upload dir (es. statistics/).
if not file_path.startswith("task_results/") or ".." in file_path.split("/"):
abort(404)
return proxy_file(file_path)
# ============================================================================
# PROXY REPORT PDF → FastAPI
# ============================================================================
+27
View File
@@ -1,5 +1,7 @@
"""TieMeasureFlow Client Configuration."""
import os
import tomllib
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
@@ -10,6 +12,25 @@ load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
def _read_app_version() -> str:
"""Versione del prodotto = `[project] version` di pyproject.toml (#70).
Si risale dalla cartella del modulo: nel container il file sta in
/app/pyproject.toml (Dockerfile.frontend), nel repo alla radice. Se non
si trova o e' illeggibile torna "dev": il piede di pagina non deve mai
rompere l'avvio.
"""
for parent in Path(__file__).resolve().parents:
candidate = parent / "pyproject.toml"
if candidate.is_file():
try:
with candidate.open("rb") as fh:
return str(tomllib.load(fh)["project"]["version"])
except (OSError, tomllib.TOMLDecodeError, KeyError):
return "dev"
return "dev"
class Config:
"""Flask client configuration."""
@@ -17,6 +38,12 @@ class Config:
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
# Versione e commit mostrati nel piede di pagina (#70). Letti una volta
# all'import. Il commit arriva dal build arg APP_COMMIT (.git non e' nel
# contesto di build, vedi .dockerignore); facoltativo: vuoto = non mostrato.
APP_VERSION: str = _read_app_version()
APP_COMMIT: str = os.environ.get("APP_COMMIT", "")[:7]
# API Server connection
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
@@ -157,7 +157,7 @@
<!-- Footer -->
<div class="text-center text-xs text-[var(--text-muted)]">
<p>TieMeasureFlow &copy; {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
<p>TieMeasureFlow &copy; {{ current_year }} - {{ _('Sistema di misurazione industriale') }}{# versione e commit (#70) #} &middot; v{{ app_version }}{% if app_commit %} &middot; {{ app_commit }}{% endif %}</p>
</div>
</div>
</div>
+1 -1
View File
@@ -107,7 +107,7 @@
<!-- Footer -->
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
Powered by <span class="font-semibold">TieMeasureFlow</span> &mdash; Tielogic
Powered by <span class="font-semibold">TieMeasureFlow</span> &mdash; Tielogic{# versione e commit (#70) #} &middot; v{{ app_version }}{% if app_commit %} &middot; {{ app_commit }}{% endif %}
</p>
</footer>
{% endblock %}
@@ -129,6 +129,74 @@
</div>
</div>
<!-- #58: esiti, note, campi e allegati dei task della sessione scelta -->
<div id="run-task-results" x-show="selectedRunId" x-cloak
class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 mb-6 shadow-sm">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Esiti dei task della sessione") }}</h2>
<p x-show="taskResultsLoading" class="text-sm text-steel">{{ _("Caricamento...") }}</p>
<p x-show="taskResultsError" x-text="taskResultsError" class="text-sm text-red-600"></p>
<template x-if="taskResults && !taskResultsLoading">
<div>
<div class="grid grid-cols-3 gap-3 mb-4 max-w-md">
<div class="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
<div class="text-2xl font-bold font-mono text-emerald-600" x-text="taskResults.counts.ok"></div>
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("OK") }}</div>
</div>
<div class="text-center p-3 rounded-lg bg-red-50 dark:bg-red-900/20">
<div class="text-2xl font-bold font-mono text-red-600" x-text="taskResults.counts.nok"></div>
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("NOK") }}</div>
</div>
<div class="text-center p-3 rounded-lg bg-[var(--bg-primary)]">
<div class="text-2xl font-bold font-mono text-[var(--text-primary)]" x-text="taskResults.counts.done"></div>
<div class="text-xs text-steel mt-1">{{ _("Fatto") }}</div>
</div>
</div>
<p x-show="taskResults.results.length === 0" class="text-sm text-steel">{{ _("Nessun esito registrato in questa sessione") }}</p>
<div x-show="taskResults.results.length > 0" class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-steel uppercase border-b border-[var(--border-color)]">
<th class="py-2 pr-3">{{ _("Ora") }}</th>
<th class="py-2 pr-3">{{ _("Task") }}</th>
<th class="py-2 pr-3">{{ _("Esito") }}</th>
<th class="py-2 pr-3">{{ _("Note") }}</th>
<th class="py-2 pr-3">{{ _("Campi") }}</th>
<th class="py-2 pr-3">{{ _("Allegati") }}</th>
<th class="py-2">{{ _("Operatore") }}</th>
</tr>
</thead>
<tbody>
<template x-for="r in taskResults.results" :key="r.id">
<tr class="border-b border-[var(--border-color)] align-top">
<td class="py-2 pr-3 font-mono whitespace-nowrap" x-text="resultTime(r.created_at)"></td>
<td class="py-2 pr-3" x-text="r.task_title"></td>
<td class="py-2 pr-3">
<span class="px-2 py-0.5 rounded text-xs font-semibold"
:class="r.outcome === 'nok' ? 'bg-red-600 text-white' : (r.outcome === 'ok' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-700')"
x-text="outcomeLabel(r.outcome)"></span>
</td>
<td class="py-2 pr-3 whitespace-pre-line" x-text="r.note"></td>
<td class="py-2 pr-3">
<template x-for="v in r.values" :key="v.key">
<div><span class="text-steel" x-text="v.label + ': '"></span><span x-text="v.value"></span></div>
</template>
</td>
<td class="py-2 pr-3">
<template x-for="a in r.attachments" :key="a.path">
<div><a :href="attachmentUrl(a.path)" target="_blank" rel="noopener"
class="text-primary hover:underline" x-text="a.name || a.label"></a></div>
</template>
</td>
<td class="py-2" x-text="r.user_name"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
<!-- Nessuna ricetta selezionata -->
<template x-if="!selectedRecipeId && !loading">
<div class="text-center py-16 text-steel">
@@ -290,8 +358,52 @@ function spcDashboard() {
reportError: '',
downloadingReport: false,
// #58: esiti dei task della sessione scelta
taskResults: null,
taskResultsLoading: false,
taskResultsError: '',
init() {
// Nothing to pre-load
// #58: il pannello esiti si ricarica a ogni cambio di sessione
this.$watch('selectedRunId', () => this.loadTaskResults());
},
async loadTaskResults() {
this.taskResults = null;
this.taskResultsError = '';
const runId = this.selectedRunId;
if (!runId) return;
this.taskResultsLoading = true;
try {
const resp = await fetch('/statistics/api/runs/' + runId + '/task-results');
const data = await resp.json();
// #58: se nel frattempo la sessione e' cambiata, il risultato e' vecchio
if (runId !== this.selectedRunId) return;
if (!resp.ok || data.error) {
this.taskResultsError = data.detail || {{ _("Errore nel caricamento dei dati")|tojson }};
} else {
this.taskResults = data;
}
} catch (e) {
console.error('Error loading task results:', e);
this.taskResultsError = {{ _("Errore di connessione al server")|tojson }};
} finally {
if (runId === this.selectedRunId) this.taskResultsLoading = false;
}
},
// #58: ora locale dell'esito (created_at e' UTC senza suffisso)
resultTime(iso) {
return iso ? new Date(iso + (iso.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
},
outcomeLabel(o) {
return o === 'ok' ? {{ _("OK")|tojson }} : (o === 'nok' ? {{ _("NOK")|tojson }} : {{ _("Fatto")|tojson }});
},
// #58: gli allegati passano dal proxy del blueprint, il browser non manda X-API-Key
attachmentUrl(path) {
return '/statistics/api/files/' + String(path).split('/').map(encodeURIComponent).join('/');
},
async onRecipeChange() {
@@ -0,0 +1,37 @@
"""Versione e commit nel piede di pagina (#70)."""
import tomllib
from pathlib import Path
from config import Config
_PYPROJECT = Path(__file__).resolve().parents[4] / "pyproject.toml"
def _pyproject_version() -> str:
with _PYPROJECT.open("rb") as fh:
return tomllib.load(fh)["project"]["version"]
def test_app_version_matches_pyproject():
# La versione mostrata e' quella di pyproject.toml, non un valore a mano
assert Config.APP_VERSION == _pyproject_version()
def test_login_page_shows_version(client):
resp = client.get("/auth/login")
assert resp.status_code == 200
assert f"v{_pyproject_version()}" in resp.get_data(as_text=True)
def test_login_page_shows_commit_when_set(client, monkeypatch):
# Commit valorizzato (gia' troncato a 7 da Config): compare nel footer
monkeypatch.setattr(Config, "APP_COMMIT", "abc1234")
assert "abc1234" in client.get("/auth/login").get_data(as_text=True)
def test_login_page_hides_commit_when_empty(client, monkeypatch):
# Build senza APP_COMMIT: solo versione, nessun separatore orfano
monkeypatch.setattr(Config, "APP_COMMIT", "")
html = client.get("/auth/login").get_data(as_text=True)
assert f"v{_pyproject_version()}" in html
assert f"v{_pyproject_version()} &middot;" not in html
@@ -130,3 +130,40 @@ class TestRunsProxy:
mock_api_client.get.return_value = {"total": 1}
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
class TestRunTaskResultsProxy:
"""#58: esiti dei task e allegati della sessione."""
def test_task_results_proxy(self, logged_in_client, mock_api_client):
payload = {"run_id": 5, "counts": {"ok": 1, "nok": 0, "done": 0}, "results": []}
mock_api_client.get.return_value = payload
resp = logged_in_client.get("/statistics/api/runs/5/task-results")
assert resp.status_code == 200
assert resp.get_json() == payload
assert mock_api_client.get.call_args.args[0] == "/api/statistics/runs/5/task-results"
def test_task_results_proxy_relays_error(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"error": True, "detail": "x", "status_code": 404}
resp = logged_in_client.get("/statistics/api/runs/9/task-results")
assert resp.status_code == 404
assert resp.get_json()["error"] is True
def test_file_proxy_serves_task_results_only(self, logged_in_client):
from unittest.mock import patch
from flask import Response
with patch("blueprints.statistics.proxy_file",
return_value=Response("img", status=200)) as pf:
ok = logged_in_client.get("/statistics/api/files/task_results/a.jpg")
assert ok.status_code == 200
pf.assert_called_once_with("task_results/a.jpg")
for bad in ("statistics/x", "task_results/../statistics/x", "../etc/passwd"):
assert logged_in_client.get(f"/statistics/api/files/{bad}").status_code == 404
assert pf.call_count == 1 # i percorsi rifiutati non arrivano al proxy
def test_dashboard_has_task_results_panel(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
assert 'id="run-task-results"' in html
assert "loadTaskResults" in html
assert "/statistics/api/runs/" in html
@@ -2977,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"
@@ -2981,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"