Compare commits

...

10 Commits

Author SHA1 Message Date
Adriano fc1902a1bb Ricetta: il digest della cartella copre anche i file di meta (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:27:04 +02:00
Adriano 86290274e5 Test ricetta v2, execute dxf_compare, migrazione 016 (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:15:02 +02:00
Adriano ee0f65ed39 Endpoint ricetta VisionSuite v2, execute con overlay e immagine salvata (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:12:50 +02:00
Adriano 18d0db8a0c Migrazione 016 vision_recipe_dir, copy-on-write, dxf_compare tra i task di misura (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:08:29 +02:00
Adriano a829bf3eb0 frontend: date UTC convertite in ora italiana alla presentazione (#42)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:59:18 +02:00
Adriano 7ac291b8ac Backend: date dei filtri dei report e utenti setup in ora italiana/UTC esplicito (#42)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:59:07 +02:00
Adriano 486e1288bf Backend: UTC nel DB, +00:00 nelle API, ora italiana solo in presentazione (#42)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:41:22 +02:00
Adriano 0d004c43ff Login: cancella le key scadute dell'utente (#41)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:34:30 +02:00
Adriano 35f46926a9 API key come hash SHA-256, una per sessione, TTL rinnovabile (#41)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:34:30 +02:00
Adriano 1d93eded27 Ripristino delle quote già misurate nel ciclo in corso dopo un reload (#40)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:31:57 +02:00
54 changed files with 1789 additions and 197 deletions
+1
View File
@@ -14,6 +14,7 @@ services:
--authentication-policy=mysql_native_password --authentication-policy=mysql_native_password
--character-set-server=utf8mb4 --character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci --collation-server=utf8mb4_unicode_ci
--default-time-zone=+00:00
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s interval: 10s
+1
View File
@@ -14,6 +14,7 @@ services:
--authentication-policy=mysql_native_password --authentication-policy=mysql_native_password
--character-set-server=utf8mb4 --character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci --collation-server=utf8mb4_unicode_ci
--default-time-zone=+00:00
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s interval: 10s
+22 -4
View File
@@ -1,10 +1,18 @@
"""API Key authentication dependency for FastAPI.""" """API Key authentication dependency for FastAPI."""
from datetime import timedelta
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db from src.backend.database import get_db
from src.backend.config import settings
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.services.auth_service import hash_api_key
from src.backend.utils.tempo import utcnow
_RENEW_INTERVAL = timedelta(minutes=5)
async def get_current_user( async def get_current_user(
@@ -24,15 +32,25 @@ async def get_current_user(
) )
result = await db.execute( result = await db.execute(
select(User).where(User.api_key == api_key, User.active == True) select(ApiKey, User)
.join(User, User.id == ApiKey.user_id)
.where(ApiKey.key_hash == hash_api_key(api_key), User.active == True)
) )
user = result.scalar_one_or_none() row = result.first()
now = utcnow()
if user is None: if row is None or row[0].expires_at <= now:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or inactive API key", detail="Invalid or expired API key",
) )
key_row, user = row
# Sliding TTL, written at most once per _RENEW_INTERVAL to spare the DB.
if key_row.last_used_at is None or now - key_row.last_used_at > _RENEW_INTERVAL:
key_row.last_used_at = now
key_row.expires_at = now + timedelta(hours=settings.api_key_ttl_hours)
await db.flush()
# Store user_id in request state for access logging middleware # Store user_id in request state for access logging middleware
request.state.user_id = user.id request.state.user_id = user.id
+7 -6
View File
@@ -1,5 +1,5 @@
"""Authentication router - login, logout, profile.""" """Authentication router - login, logout, profile."""
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db from src.backend.database import get_db
@@ -34,15 +34,16 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout( async def logout(
request: Request,
all_sessions: bool = False, all_sessions: bool = False,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Logout. The API key is shared across stations logged in as the same """Logout. Deletes this session's API key only; ?all_sessions=true
user, so by default this does not invalidate it - only this station's deletes every key of the user."""
session (its Flask session, on the client side) ends. Pass await logout_user(
?all_sessions=true to invalidate the key everywhere.""" db, user, key=request.headers.get("X-API-Key"), all_sessions=all_sessions
await logout_user(db, user, all_sessions=all_sessions) )
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
+27 -6
View File
@@ -2,6 +2,8 @@
import csv import csv
import io import io
from datetime import datetime from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from src.backend.utils.tempo import as_utc_naive, iso_utc, to_local, utcnow
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -19,6 +21,7 @@ from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.setting import SystemSetting from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.models.api.measurement import ( from src.backend.models.api.measurement import (
CurrentCycleResponse,
MeasurementAuthorisation, MeasurementAuthorisation,
MeasurementBatchCreate, MeasurementBatchCreate,
MeasurementCreate, MeasurementCreate,
@@ -97,6 +100,24 @@ async def get_task_progress(
return TaskProgressListResponse(tasks=tasks) return TaskProgressListResponse(tasks=tasks)
@router.get("/current-cycle", response_model=CurrentCycleResponse)
async def get_current_cycle(
production_run_id: int = Query(..., gt=0),
task_id: int = Query(..., gt=0),
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Quotes of this task already measured in the cycle under way.
Asked on load, so that a reload mid-cycle does not present quotes again that
were taken a moment ago.
"""
rows = await measurement_service.current_cycle_measurements(
db, production_run_id, task_id,
)
return CurrentCycleResponse(measurements=rows)
@router.post("/{measurement_id}/authorise", response_model=MeasurementResponse) @router.post("/{measurement_id}/authorise", response_model=MeasurementResponse)
async def authorise_measurement( async def authorise_measurement(
measurement_id: int, measurement_id: int,
@@ -213,8 +234,8 @@ async def get_measurements(
measured_by: int | None = Query(None), measured_by: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
production_run_id: int | None = Query(None, gt=0), production_run_id: int | None = Query(None, gt=0),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@@ -271,8 +292,8 @@ async def export_measurements_csv(
measured_by: int | None = Query(None), measured_by: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -353,7 +374,7 @@ async def export_measurements_csv(
neutralise_csv_cell(m.lot_number or ""), neutralise_csv_cell(m.lot_number or ""),
neutralise_csv_cell(m.serial_number or ""), neutralise_csv_cell(m.serial_number or ""),
m.input_method, m.input_method,
m.measured_at.isoformat(), iso_utc(m.measured_at),
m.synced_to_csv, m.synced_to_csv,
]) ])
@@ -363,6 +384,6 @@ async def export_measurements_csv(
iter([output.getvalue()]), iter([output.getvalue()]),
media_type="text/csv", media_type="text/csv",
headers={ headers={
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" "Content-Disposition": f"attachment; filename=measurements_{to_local(utcnow()).strftime('%Y%m%d_%H%M%S')}.csv"
}, },
) )
+5 -5
View File
@@ -1,5 +1,5 @@
"""Reports router — PDF report generation endpoints for Metrologist.""" """Reports router — PDF report generation endpoints for Metrologist."""
from datetime import datetime from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response from fastapi.responses import Response
@@ -18,8 +18,8 @@ async def download_spc_report(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
@@ -52,8 +52,8 @@ async def download_measurement_report(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int | None = Query(None), subtask_id: int | None = Query(None),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
+9 -12
View File
@@ -5,8 +5,8 @@ NOT protected by API Key auth (standalone access).
""" """
import hmac import hmac
import random import random
import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from src.backend.utils.tempo import iso_utc, utcnow
from pathlib import Path from pathlib import Path
from alembic import command from alembic import command
@@ -25,7 +25,7 @@ from src.backend.models.orm import (
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
) )
from src.backend.models.orm.station import Station, StationRecipeAssignment from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.services.auth_service import hash_password from src.backend.services.auth_service import hash_password, issue_api_key, revoke_all_keys
from src.backend.services.measurement_service import calculate_pass_fail from src.backend.services.measurement_service import calculate_pass_fail
router = APIRouter(prefix="/api/setup", tags=["setup"]) router = APIRouter(prefix="/api/setup", tags=["setup"])
@@ -321,8 +321,6 @@ async def create_admin(body: CreateAdminBody):
"""Create an admin user with all roles.""" """Create an admin user with all roles."""
_check_password(body.password) _check_password(body.password)
api_key = secrets.token_hex(32)
async with async_session_factory() as session: async with async_session_factory() as session:
async with session.begin(): async with session.begin():
user = User( user = User(
@@ -332,9 +330,10 @@ async def create_admin(body: CreateAdminBody):
display_name=body.admin_display_name, display_name=body.admin_display_name,
roles=["Maker", "MeasurementTec", "Metrologist"], roles=["Maker", "MeasurementTec", "Metrologist"],
is_admin=True, is_admin=True,
api_key=api_key,
) )
session.add(user) session.add(user)
await session.flush()
api_key = await issue_api_key(session, user)
return { return {
"status": "ok", "status": "ok",
@@ -404,7 +403,6 @@ async def seed_demo_data(body: PasswordBody):
email=u["email"], email=u["email"],
roles=u["roles"], roles=u["roles"],
is_admin=u["is_admin"], is_admin=u["is_admin"],
api_key=secrets.token_hex(32),
) )
session.add(user) session.add(user)
created_users.append(user) created_users.append(user)
@@ -558,7 +556,7 @@ async def seed_demo_data(body: PasswordBody):
tec_user = created_users[2] # tec1 tec_user = created_users[2] # tec1
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"] lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)] serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
base_date = datetime.now() base_date = utcnow()
all_subtasks = subtasks_1 + subtasks_2 all_subtasks = subtasks_1 + subtasks_2
measurements_created = 0 measurements_created = 0
@@ -606,8 +604,8 @@ async def list_users(body: PasswordBody):
"roles": u.roles or [], "roles": u.roles or [],
"is_admin": u.is_admin, "is_admin": u.is_admin,
"active": u.active, "active": u.active,
"created_at": u.created_at.isoformat() if u.created_at else None, "created_at": iso_utc(u.created_at) or None,
"last_login": u.last_login.isoformat() if u.last_login else None, "last_login": iso_utc(u.last_login) or None,
} }
for u in users for u in users
], ],
@@ -652,7 +650,6 @@ async def manage_user(body: ManageUserBody):
email=body.email, email=body.email,
roles=body.roles, roles=body.roles,
is_admin=body.is_admin, is_admin=body.is_admin,
api_key=secrets.token_hex(32),
) )
session.add(user) session.add(user)
await session.flush() await session.flush()
@@ -707,7 +704,7 @@ async def change_user_password(body: ChangeUserPasswordBody):
detail=f"User ID {body.user_id} not found", detail=f"User ID {body.user_id} not found",
) )
user.password_hash = hash_password(body.new_password) user.password_hash = hash_password(body.new_password)
user.api_key = None # Invalidate sessions await revoke_all_keys(session, user.id) # Invalidate sessions
return {"status": "ok", "message": f"Password changed for '{user.username}'"} return {"status": "ok", "message": f"Password changed for '{user.username}'"}
@@ -730,7 +727,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
) )
user.active = not user.active user.active = not user.active
if not user.active: if not user.active:
user.api_key = None # Invalidate sessions on deactivation await revoke_all_keys(session, user.id) # on deactivation
new_status = "active" if user.active else "inactive" new_status = "active" if user.active else "inactive"
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active} return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
+9 -8
View File
@@ -1,5 +1,6 @@
"""Statistics router - SPC endpoints for Metrologist dashboard.""" """Statistics router - SPC endpoints for Metrologist dashboard."""
from datetime import datetime from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, select from sqlalchemy import and_, select
@@ -100,8 +101,8 @@ async def get_summary(
recipe_id: int = Query(...), recipe_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
subtask_id: int | None = Query(None), subtask_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
@@ -122,8 +123,8 @@ async def get_capability(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
@@ -145,8 +146,8 @@ async def get_control_chart(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
@@ -172,8 +173,8 @@ async def get_histogram(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
+4 -4
View File
@@ -7,7 +7,7 @@ from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_admin_user from src.backend.api.middleware.api_key import require_admin_user
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key, revoke_all_keys
router = APIRouter(prefix="/api/users", tags=["users"]) router = APIRouter(prefix="/api/users", tags=["users"])
@@ -146,7 +146,7 @@ async def change_user_password(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.password_hash = hash_password(data.password) user.password_hash = hash_password(data.password)
user.api_key = None await revoke_all_keys(db, user.id)
await db.flush() await db.flush()
return {"message": f"Password changed for user {user.username}"} return {"message": f"Password changed for user {user.username}"}
@@ -168,7 +168,7 @@ async def deactivate_user(
detail="Cannot deactivate yourself", detail="Cannot deactivate yourself",
) )
user.active = False user.active = False
user.api_key = None await revoke_all_keys(db, user.id)
await db.flush() await db.flush()
@@ -183,5 +183,5 @@ async def regenerate_user_key(
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
new_key = await regenerate_api_key(db, user_id) new_key = await regenerate_api_key(db, user)
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"} return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
+115 -3
View File
@@ -5,19 +5,25 @@ the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes. the frontend or the recipes.
""" """
import io import io
import json
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from PIL import Image from PIL import Image
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, validate_file_size from src.backend.api.routers.files import (
ALLOWED_IMAGE_TYPES, resolve_upload_path, validate_file_size,
)
from src.backend.config import settings from src.backend.config import settings
from src.backend.database import get_db from src.backend.database import get_db
from src.backend.models.api.vision import ( from src.backend.models.api.vision import (
VisionExecuteResponse, VisionMeasurementResult, VisionReferenceImageResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionRecipeUploadResponse, VisionReferenceImageResponse,
) )
from src.backend.models.orm.vision import VisionResult
from src.backend.models.orm.task import RecipeTask from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.services import vision_service from src.backend.services import vision_service
@@ -126,7 +132,7 @@ async def execute(
) -> VisionExecuteResponse: ) -> VisionExecuteResponse:
task = await _task_or_404(db, task_id) task = await _task_or_404(db, task_id)
result, saved = await vision_service.execute_task( result, saved, report = await vision_service.execute_task(
db, db,
task, task,
await _read_image(image), await _read_image(image),
@@ -135,6 +141,7 @@ async def execute(
lot_number=lot_number, lot_number=lot_number,
serial_number=serial_number, serial_number=serial_number,
production_run_id=production_run_id, production_run_id=production_run_id,
image_ext=_IMAGE_EXTENSIONS.get(image.content_type, "png"),
) )
await db.commit() await db.commit()
@@ -147,6 +154,111 @@ async def execute(
) )
for m in saved for m in saved
], ],
outcome=report.get("outcome"),
overlay=report.get("overlay") or [],
image_url=f"/api/vision/results/{result.id}/image",
failures=report.get("failures") or [],
)
@router.get("/results/{result_id}/image")
async def get_result_image(
result_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> FileResponse:
"""The image a vision run measured, as saved by `execute` (#46)."""
result = (await db.execute(
select(VisionResult).where(VisionResult.id == result_id)
)).scalar_one_or_none()
if result is None or not result.image_path:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Result image not found",
)
return FileResponse(resolve_upload_path(result.image_path))
def _422(errors: list[str]) -> HTTPException:
# detail e' una lista di stringhe: il frontend legge i 422 solo da `detail`.
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=errors,
)
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
async def upload_recipe(
task_id: int,
recipe: UploadFile = File(...),
files: list[UploadFile] = File(default=[]),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> VisionRecipeUploadResponse:
"""Load a VisionSuite v2 recipe plus its meta files onto a task (#46)."""
task = await _task_or_404(db, task_id)
if task.task_type not in vision_service.VISION_TASK_TYPES:
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
raw = await recipe.read()
if not validate_file_size(len(raw)):
raise _422([f"recipe exceeds {settings.max_upload_size_mb}MB"])
try:
graph = json.loads(raw)
except ValueError:
raise _422(["the recipe is not valid JSON"])
if not isinstance(graph, dict):
raise _422(["the recipe must be a JSON object"])
uploads: list[tuple[str, bytes]] = []
for f in files:
content = await f.read()
if not validate_file_size(len(content)):
raise _422([f"file {f.filename!r} exceeds {settings.max_upload_size_mb}MB"])
uploads.append((f.filename or "", content))
placed, errors = vision_service.match_recipe_files(graph, uploads)
if errors:
raise _422(errors)
# The folder is content-addressed and immutable; writing it before the
# validation is harmless (an orphan folder is never referenced) and the
# worker needs it on disk to validate meta files.
try:
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
except ValueError as exc:
raise _422([str(exc)])
check = await vision_service.validate_recipe(graph, recipe_dir)
if not check["ok"]:
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
task.vision_json = graph
task.vision_recipe_dir = recipe_dir
await db.commit()
return VisionRecipeUploadResponse(
task_id=task.id,
recipe_dir=recipe_dir,
files=vision_service.list_recipe_files(recipe_dir),
outputs=check["outputs"],
)
@router.get("/tasks/{task_id}/recipe", response_model=VisionRecipeResponse)
async def get_recipe(
task_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> VisionRecipeResponse:
task = await _task_or_404(db, task_id)
outputs: list[dict] = []
if task.vision_json:
check = await vision_service.validate_recipe(
task.vision_json, task.vision_recipe_dir,
)
outputs = check.get("outputs", [])
return VisionRecipeResponse(
task_id=task.id,
recipe_dir=task.vision_recipe_dir,
recipe=task.vision_json,
files=vision_service.list_recipe_files(task.vision_recipe_dir),
outputs=outputs,
) )
+6
View File
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
db_user: str = "tmflow" db_user: str = "tmflow"
db_password: str = _DEFAULT_DB_PASSWORD db_password: str = _DEFAULT_DB_PASSWORD
# Presentation time zone (DB is UTC, #42)
display_timezone: str = "Europe/Rome"
# Server # Server
server_host: str = "0.0.0.0" server_host: str = "0.0.0.0"
server_port: int = 8000 server_port: int = 8000
@@ -38,6 +41,9 @@ class Settings(BaseSettings):
# configured limits to keep the cluster-wide effective limit close to what # configured limits to keep the cluster-wide effective limit close to what
# was configured instead of multiplying it by N. # was configured instead of multiplying it by N.
web_concurrency: int = 1 web_concurrency: int = 1
# API key session lifetime (hours); renewed on use (env API_KEY_TTL_HOURS).
api_key_ttl_hours: int = 24
# How many reverse proxies between the client and this API are trusted to # How many reverse proxies between the client and this API are trusted to
# have appended their own hop to X-Forwarded-For (Nginx, in the default # have appended their own hop to X-Forwarded-For (Nginx, in the default
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header # deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
@@ -0,0 +1,54 @@
"""API keys as SHA-256 hashes, one per session (#41)
Replaces the plaintext `users.api_key` column with an `api_keys` table: one
row per login, holding only the SHA-256 hex digest, an expiry and the last
use. Existing keys are invalidated on purpose, not migrated - the plaintext
is exactly what must stop existing, so everybody logs in once again.
Revision ID: 015_api_key_hash
Revises: 014_vision_fk_ondelete
Create Date: 2026-10-06
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '015_api_key_hash'
down_revision: Union[str, None] = '014_vision_fk_ondelete'
branch_labels: Union[str, Sequence[str], None] = None
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'])
with op.batch_alter_table('users') as batch:
batch.drop_index('ix_users_api_key')
batch.drop_constraint('uq_users_api_key', type_='unique')
batch.drop_column('api_key')
def downgrade() -> None:
with op.batch_alter_table('users') as batch:
batch.add_column(sa.Column('api_key', sa.String(64), nullable=True))
batch.create_unique_constraint('uq_users_api_key', ['api_key'])
batch.create_index('ix_users_api_key', ['api_key'])
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
op.drop_table('api_keys')
@@ -0,0 +1,30 @@
"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46)
Percorso relativo sotto upload_path della cartella immutabile che contiene
recipe.json e i file di meta (DXF, modelli .npz, ...).
Revision ID: 016_vision_recipe_dir
Revises: 015_api_key_hash
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '016_vision_recipe_dir'
down_revision: Union[str, None] = '015_api_key_hash'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.drop_column('vision_recipe_dir')
+20 -5
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Measurement operations.""" """Pydantic schemas for Measurement operations."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -54,8 +54,8 @@ class MeasurementResponse(BaseModel):
# Who let this value stand, when it was out of tolerance. Null on a value in # Who let this value stand, when it was out of tolerance. Null on a value in
# tolerance - and on one that is not and is still waiting for an answer. # tolerance - and on one that is not and is still waiting for an answer.
supervisor_id: Optional[int] = None supervisor_id: Optional[int] = None
authorised_at: Optional[datetime] = None authorised_at: Optional[UtcDateTime] = None
measured_at: datetime measured_at: UtcDateTime
synced_to_csv: bool synced_to_csv: bool
@@ -91,6 +91,21 @@ class TaskProgressListResponse(BaseModel):
tasks: list[TaskProgressResponse] tasks: list[TaskProgressResponse]
class CycleMeasurementResponse(BaseModel):
"""One quote already measured in the cycle under way."""
subtask_id: int
value: float
pass_fail: str
measured_at: UtcDateTime
class CurrentCycleResponse(BaseModel):
"""The quotes to restore on the screen after a reload; empty if the task is closed."""
measurements: list[CycleMeasurementResponse]
class MeasurementQuery(BaseModel): class MeasurementQuery(BaseModel):
"""Schema for measurement query filters.""" """Schema for measurement query filters."""
recipe_id: Optional[int] = None recipe_id: Optional[int] = None
@@ -99,8 +114,8 @@ class MeasurementQuery(BaseModel):
measured_by: Optional[int] = None measured_by: Optional[int] = None
lot_number: Optional[str] = None lot_number: Optional[str] = None
serial_number: Optional[str] = None serial_number: Optional[str] = None
date_from: Optional[datetime] = None date_from: UtcNaiveInput = None
date_to: Optional[datetime] = None date_to: UtcNaiveInput = None
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$") pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
page: int = Field(1, ge=1) page: int = Field(1, ge=1)
per_page: int = Field(50, ge=1, le=500) per_page: int = Field(50, ge=1, le=500)
+7 -7
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for production runs and their events.""" """Pydantic schemas for production runs and their events."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from typing import Literal, Optional from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -21,7 +21,7 @@ class ProductionEventResponse(BaseModel):
supervisor_id: Optional[int] supervisor_id: Optional[int]
task_id: Optional[int] = None task_id: Optional[int] = None
note: Optional[str] note: Optional[str]
created_at: datetime created_at: UtcDateTime
class ProductionRunResponse(BaseModel): class ProductionRunResponse(BaseModel):
@@ -36,11 +36,11 @@ class ProductionRunResponse(BaseModel):
serial_number: Optional[str] serial_number: Optional[str]
status: str status: str
measurement_interval_minutes: Optional[int] measurement_interval_minutes: Optional[int]
next_measurement_at: Optional[datetime] next_measurement_at: Optional[UtcDateTime]
cycle_count: int cycle_count: int
started_at: datetime started_at: UtcDateTime
paused_at: Optional[datetime] paused_at: Optional[UtcDateTime]
closed_at: Optional[datetime] closed_at: Optional[UtcDateTime]
closed_by: Optional[int] closed_by: Optional[int]
statistics_path: Optional[str] = None statistics_path: Optional[str] = None
@@ -49,7 +49,7 @@ class ProductionRunResponse(BaseModel):
# overdue is a fact the operator must see, not deduce. # overdue is a fact the operator must see, not deduce.
seconds_to_next_measurement: Optional[int] = None seconds_to_next_measurement: Optional[int] = None
overdue: bool = False overdue: bool = False
server_time: Optional[datetime] = None server_time: Optional[UtcDateTime] = None
# The measurement tasks of this run's version, in execution order. The client # The measurement tasks of this run's version, in execution order. The client
# needs them to bring the operator back to the measurement when the interval # needs them to bring the operator back to the measurement when the interval
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Recipe and RecipeVersion operations.""" """Pydantic schemas for Recipe and RecipeVersion operations."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -51,7 +51,7 @@ class RecipeVersionResponse(BaseModel):
version_number: int version_number: int
is_current: bool is_current: bool
created_by: int created_by: int
created_at: datetime created_at: UtcDateTime
change_notes: Optional[str] = None change_notes: Optional[str] = None
tasks: list["TaskResponse"] = [] tasks: list["TaskResponse"] = []
@@ -72,7 +72,7 @@ class RecipeResponse(BaseModel):
requires_serial: bool = False requires_serial: bool = False
allow_manual_input: bool = False allow_manual_input: bool = False
created_by: int created_by: int
created_at: datetime created_at: UtcDateTime
active: bool active: bool
current_version: Optional[RecipeVersionResponse] = None current_version: Optional[RecipeVersionResponse] = None
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Station and StationRecipeAssignment.""" """Pydantic schemas for Station and StationRecipeAssignment."""
from datetime import datetime 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
@@ -29,7 +29,7 @@ class StationResponse(BaseModel):
notes: Optional[str] notes: Optional[str]
active: bool active: bool
created_by: int created_by: int
created_at: datetime created_at: UtcDateTime
class StationRecipeAssignmentCreate(BaseModel): class StationRecipeAssignmentCreate(BaseModel):
@@ -42,7 +42,7 @@ class StationRecipeAssignmentResponse(BaseModel):
station_id: int station_id: int
recipe_id: int recipe_id: int
assigned_by: int assigned_by: int
assigned_at: datetime assigned_at: UtcDateTime
class RecipeSummary(BaseModel): class RecipeSummary(BaseModel):
+5 -5
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for SPC statistics responses.""" """Pydantic schemas for SPC statistics responses."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
recipe_id: int recipe_id: int
version_id: Optional[int] = None version_id: Optional[int] = None
subtask_id: Optional[int] = None subtask_id: Optional[int] = None
date_from: Optional[datetime] = None date_from: UtcNaiveInput = None
date_to: Optional[datetime] = None date_to: UtcNaiveInput = None
operator_id: Optional[int] = None operator_id: Optional[int] = None
lot_number: Optional[str] = None lot_number: Optional[str] = None
serial_number: Optional[str] = None serial_number: Optional[str] = None
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
class ControlChartData(BaseModel): class ControlChartData(BaseModel):
"""Data for X-bar/R control chart.""" """Data for X-bar/R control chart."""
values: list[float] values: list[float]
timestamps: list[datetime] timestamps: list[UtcDateTime]
mean: float mean: float
ucl: float # Upper Control Limit ucl: float # Upper Control Limit
lcl: float # Lower Control Limit lcl: float # Lower Control Limit
@@ -60,7 +60,7 @@ class CapabilityData(BaseModel):
class TrendData(BaseModel): class TrendData(BaseModel):
"""Temporal trend data for capability indices.""" """Temporal trend data for capability indices."""
dates: list[datetime] dates: list[UtcDateTime]
cpk_values: list[Optional[float]] cpk_values: list[Optional[float]]
ppk_values: list[Optional[float]] ppk_values: list[Optional[float]]
n_per_period: list[int] n_per_period: list[int]
+14
View File
@@ -0,0 +1,14 @@
"""Shared Pydantic types for datetimes (#42): UTC in the DB, explicit +00:00 on the wire."""
from datetime import datetime
from typing import Annotated, Optional
from pydantic import AfterValidator, PlainSerializer
from src.backend.utils.tempo import as_utc_naive, to_utc_aware
UtcDateTime = Annotated[
datetime, PlainSerializer(lambda v: to_utc_aware(v).isoformat(), return_type=str)
]
# Input: aware values are converted to UTC naive, naive is taken as UTC.
UtcNaiveInput = Annotated[Optional[datetime], AfterValidator(as_utc_naive)]
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for User operations.""" """Pydantic schemas for User operations."""
from datetime import datetime 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
@@ -48,8 +48,8 @@ class UserResponse(BaseModel):
language_pref: str language_pref: str
theme_pref: str theme_pref: str
active: bool active: bool
created_at: datetime created_at: UtcDateTime
last_login: Optional[datetime] = None last_login: Optional[UtcDateTime] = None
class UserPasswordChange(BaseModel): class UserPasswordChange(BaseModel):
+30 -2
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for the vision execution response.""" """Pydantic schemas for the vision execution response."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -16,6 +16,34 @@ class VisionExecuteResponse(BaseModel):
vision_result_id: int vision_result_id: int
engine_version: str engine_version: str
measurements: list[VisionMeasurementResult] measurements: list[VisionMeasurementResult]
# Esito VisionSuite (informativo: il verdetto lo danno utl/ltl), overlay
# in px immagine, URL dell'immagine salvata e strumenti falliti (#46).
outcome: str | None = None
overlay: list[dict] = []
image_url: str | None = None
failures: list[dict] = []
class VisionRecipeOutput(BaseModel):
"""One numeric output a recipe offers to the quotes."""
key: str
quantity: str | None = None
unit: str | None = None
class VisionRecipeUploadResponse(BaseModel):
task_id: int
recipe_dir: str
files: list[str]
outputs: list[VisionRecipeOutput]
class VisionRecipeResponse(BaseModel):
task_id: int
recipe_dir: str | None = None
recipe: dict | None = None
files: list[str]
outputs: list[VisionRecipeOutput]
class VisionReferenceImageResponse(BaseModel): class VisionReferenceImageResponse(BaseModel):
@@ -28,4 +56,4 @@ class VisionReferenceImageResponse(BaseModel):
note: str | None = None note: str | None = None
station_id: int | None = None station_id: int | None = None
device_code: str | None = None device_code: str | None = None
acquired_at: datetime acquired_at: UtcDateTime
+2
View File
@@ -1,5 +1,6 @@
"""SQLAlchemy models for TieMeasureFlow.""" """SQLAlchemy models for TieMeasureFlow."""
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.recipe import Recipe, RecipeVersion from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeTask, RecipeSubtask from src.backend.models.orm.task import RecipeTask, RecipeSubtask
from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.measurement import Measurement
@@ -11,6 +12,7 @@ from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
__all__ = [ __all__ = [
"User", "User",
"ApiKey",
"Recipe", "Recipe",
"RecipeVersion", "RecipeVersion",
"RecipeTask", "RecipeTask",
+25
View File
@@ -0,0 +1,25 @@
"""API key sessions: one row per login, stored as a SHA-256 hash."""
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from src.backend.database import Base
class ApiKey(Base):
"""One API key per login session.
Only the SHA-256 hex digest is stored, never the key. Dates are naive UTC.
"""
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
+5 -1
View File
@@ -18,7 +18,8 @@ if TYPE_CHECKING:
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure") TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
# Types the operator screen treats as "there are quotes to take here". # Types the operator screen treats as "there are quotes to take here".
MEASURING_TASK_TYPES = ("measure", "camera_measure") # dxf_compare produce quote come camera_measure (#46)
MEASURING_TASK_TYPES = ("measure", "camera_measure", "dxf_compare")
class RecipeTask(Base): class RecipeTask(Base):
@@ -51,6 +52,9 @@ class RecipeTask(Base):
# server routes it and never interprets it, which is what keeps VisionSuite # server routes it and never interprets it, which is what keeps VisionSuite
# out of this container. # out of this container.
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
# meta della ricetta VisionSuite v2 (#46).
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Relationships # Relationships
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks") version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
-1
View File
@@ -32,7 +32,6 @@ class User(Base):
# Status # Status
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
api_key: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
+63 -35
View File
@@ -1,12 +1,16 @@
"""Authentication service - password hashing, API key management.""" """Authentication service - password hashing, API key management."""
import hashlib
import secrets import secrets
from datetime import datetime from datetime import datetime, timedelta
from src.backend.utils.tempo import utcnow
import bcrypt import bcrypt
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import select, update from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
@@ -58,8 +62,8 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
of them because it is the same question every time - who is this, and are they of them because it is the same question every time - who is this, and are they
allowed to say yes. allowed to say yes.
Uses authenticate_user rather than a full login: logging in would rotate the Uses authenticate_user rather than a full login: logging in would issue a
supervisor's API key and knock out whatever session they have open elsewhere. needless API key for the supervisor.
""" """
supervisor = await authenticate_user(db, username, password) supervisor = await authenticate_user(db, username, password)
if supervisor is None: if supervisor is None:
@@ -75,39 +79,67 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
return supervisor return supervisor
async def login_user(db: AsyncSession, user: User) -> str: def hash_api_key(key: str) -> str:
"""Generate (or reuse) the API key and update last_login for user. """SHA-256 hex digest of an API key; this is all the DB ever stores."""
return hashlib.sha256(key.encode("utf-8")).hexdigest()
Reuses an existing key rather than rotating it on every login: the same
account logged in from two stations at once (a common shift-account async def issue_api_key(db: AsyncSession, user: User) -> str:
pattern here) must keep both sessions working. Rotating the key would """Create a new session key for the user and return it in clear (once)."""
silently log the other station out. key = generate_api_key()
""" now = utcnow()
api_key = user.api_key or generate_api_key() # Housekeeping: drop this user's expired keys so the table does not grow forever.
await db.execute( await db.execute(
update(User) delete(ApiKey).where(ApiKey.user_id == user.id, ApiKey.expires_at <= now)
.where(User.id == user.id) )
.values(api_key=api_key, last_login=datetime.utcnow()) db.add(
ApiKey(
user_id=user.id,
key_hash=hash_api_key(key),
created_at=now,
expires_at=now + timedelta(hours=settings.api_key_ttl_hours),
last_used_at=now,
)
)
await db.flush()
return key
async def revoke_all_keys(db: AsyncSession, user_id: int) -> None:
"""Invalidate every session key of a user."""
await db.execute(delete(ApiKey).where(ApiKey.user_id == user_id))
await db.flush()
async def login_user(db: AsyncSession, user: User) -> str:
"""Issue a new API key for this login and update last_login.
One key per login: the same account used from several stations at once
(#25) keeps every station working, because no login revokes another's key.
"""
api_key = await issue_api_key(db, user)
await db.execute(
update(User).where(User.id == user.id).values(last_login=utcnow())
) )
await db.flush() await db.flush()
return api_key return api_key
async def logout_user( async def logout_user(
db: AsyncSession, user: User, all_sessions: bool = False db: AsyncSession, user: User, key: str | None = None, all_sessions: bool = False
) -> None: ) -> None:
"""Log out. By default a no-op on the shared API key. """Log out. Deletes only the key of this session; all_sessions=True
deletes every key of the user."""
Clearing the key on every logout would kick out any other station still if all_sessions:
using the same shift account. `all_sessions=True` is the explicit await revoke_all_keys(db, user.id)
override for a supervisor who wants every session logged out.
"""
if not all_sessions:
return return
await db.execute( if key:
update(User).where(User.id == user.id).values(api_key=None) await db.execute(
) delete(ApiKey).where(
await db.flush() ApiKey.user_id == user.id, ApiKey.key_hash == hash_api_key(key)
)
)
await db.flush()
async def create_user( async def create_user(
@@ -138,11 +170,7 @@ async def create_user(
return user return user
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str: async def regenerate_api_key(db: AsyncSession, user: User) -> str:
"""Regenerate API key for a user.""" """Revoke every key of the user and issue a fresh one."""
new_key = generate_api_key() await revoke_all_keys(db, user.id)
await db.execute( return await issue_api_key(db, user)
update(User).where(User.id == user_id).values(api_key=new_key)
)
await db.flush()
return new_key
+70 -1
View File
@@ -1,6 +1,7 @@
"""Measurement service - pass/fail calculation, data storage.""" """Measurement service - pass/fail calculation, data storage."""
import math import math
from datetime import datetime from datetime import datetime
from src.backend.utils.tempo import utcnow
from decimal import Decimal from decimal import Decimal
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -9,6 +10,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionEvent
from src.backend.models.orm.recipe import Recipe, RecipeVersion from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import ( from src.backend.models.orm.task import (
MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask, MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask,
@@ -241,6 +243,73 @@ async def task_progress(
return progress return progress
async def current_cycle_measurements(
db: AsyncSession, run_id: int, task_id: int,
) -> list[dict]:
"""The quotes of a task already measured in the cycle that is under way.
A reload, a timer redirect or an auto-logout throws away what the page held in
memory; the operator would measure the same quotes again and the duplicates
would reach the SPC statistics. The cycle needs no column of its own: it starts
at the latest event of the run that opens one for this task - the run start, a
cycle_completed, or a remeasure of this task.
If a task_measured of this task follows that boundary the task is already
closed in this cycle and there is nothing to restore. Where a quote was measured
more than once, the last reading stands.
"""
boundary = (await db.execute(
select(ProductionEvent.id, ProductionEvent.created_at)
.where(
ProductionEvent.run_id == run_id,
(ProductionEvent.event_type.in_(("start", "cycle_completed")))
| (
(ProductionEvent.event_type == "remeasure")
& (ProductionEvent.task_id == task_id)
),
)
.order_by(ProductionEvent.id.desc())
.limit(1)
)).first()
event_filter = [ProductionEvent.run_id == run_id]
measurement_filter = [Measurement.production_run_id == run_id]
if boundary is not None:
boundary_id, boundary_at = boundary
event_filter.append(ProductionEvent.id > boundary_id)
measurement_filter.append(Measurement.measured_at > boundary_at)
closed = (await db.execute(
select(ProductionEvent.id).where(
*event_filter,
ProductionEvent.event_type == "task_measured",
ProductionEvent.task_id == task_id,
).limit(1)
)).first()
if closed is not None:
return []
rows = (await db.execute(
select(Measurement)
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id)
.where(*measurement_filter, RecipeSubtask.task_id == task_id)
.order_by(Measurement.measured_at, Measurement.id)
)).scalars().all()
latest: dict[int, Measurement] = {}
for m in rows:
latest[m.subtask_id] = m
return [
{
"subtask_id": m.subtask_id,
"value": float(m.value),
"pass_fail": m.pass_fail,
"measured_at": m.measured_at,
}
for m in sorted(latest.values(), key=lambda m: (m.measured_at, m.id))
]
async def validate_run_for_version( async def validate_run_for_version(
db: AsyncSession, run_id: int, version_id: int, db: AsyncSession, run_id: int, version_id: int,
) -> "ProductionRun": ) -> "ProductionRun":
@@ -275,7 +344,7 @@ async def authorise_measurement(
) -> Measurement: ) -> Measurement:
"""Let an out-of-tolerance value stand, on the supervisor's name.""" """Let an out-of-tolerance value stand, on the supervisor's name."""
measurement.supervisor_id = supervisor.id measurement.supervisor_id = supervisor.id
measurement.authorised_at = datetime.now() measurement.authorised_at = utcnow()
await db.flush() await db.flush()
await db.refresh(measurement) await db.refresh(measurement)
return measurement return measurement
@@ -14,6 +14,7 @@ import csv
import io import io
import logging import logging
from datetime import datetime from datetime import datetime
from src.backend.utils.tempo import iso_utc, to_local, utcnow
from pathlib import Path from pathlib import Path
from sqlalchemy import select, update from sqlalchemy import select, update
@@ -169,9 +170,9 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
measurement.input_method, measurement.input_method,
"" if measurement.input_duration_ms is None else measurement.input_duration_ms, "" if measurement.input_duration_ms is None else measurement.input_duration_ms,
measurement.measured_by, measurement.measured_by,
measurement.measured_at.isoformat() if measurement.measured_at else "", iso_utc(measurement.measured_at),
measurement.supervisor_id or "", measurement.supervisor_id or "",
measurement.authorised_at.isoformat() if measurement.authorised_at else "", iso_utc(measurement.authorised_at),
]) ])
return output.getvalue() return output.getvalue()
@@ -191,7 +192,7 @@ async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | N
directory = Path(settings.upload_path) / EXPORT_SUBDIR directory = Path(settings.upload_path) / EXPORT_SUBDIR
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S") stamp = to_local(run.closed_at or utcnow()).strftime("%Y%m%d_%H%M%S")
filename = f"production_{run.id}_{stamp}.csv" filename = f"production_{run.id}_{stamp}.csv"
# A large run's file can take a moment to write: off the event loop, so # A large run's file can take a moment to write: off the event loop, so
# closing one run does not stall every other request being served. # closing one run does not stall every other request being served.
+3 -2
View File
@@ -14,6 +14,7 @@ the countdown would be off by the UTC offset. The number is the contract; the
timestamp is only there for the audit trail. timestamp is only there for the audit trail.
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
from src.backend.utils.tempo import utcnow
from typing import Optional from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -34,10 +35,10 @@ from src.backend.services import (
def _now() -> datetime: def _now() -> datetime:
"""Single source of time for runs. """Single source of time for runs.
Naive local time, matching what the database writes for ``func.now()`` defaults, Naive UTC, matching what the database writes for ``func.now()`` defaults,
so a run's started_at and its deadline are comparable. so a run's started_at and its deadline are comparable.
""" """
return datetime.now() return utcnow()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2
View File
@@ -115,6 +115,8 @@ async def _copy_tasks_to_version(
# a camera_measure task unable to execute from the moment a # a camera_measure task unable to execute from the moment a
# recipe with measurements is next edited. # recipe with measurements is next edited.
vision_json=task.vision_json, vision_json=task.vision_json,
# Cartella immutabile: basta il riferimento (#46).
vision_recipe_dir=task.vision_recipe_dir,
) )
db.add(new_task) db.add(new_task)
await db.flush() # get new_task.id await db.flush() # get new_task.id
+13 -7
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import base64 import base64
from datetime import datetime from datetime import datetime
from src.backend.utils.tempo import to_local, utcnow
from pathlib import Path from pathlib import Path
import plotly.graph_objects as go import plotly.graph_objects as go
@@ -32,6 +33,11 @@ _jinja_env = Environment(
) )
def _giorno_locale(dt: datetime) -> str:
"""UTC naive filter bound -> local calendar day (YYYY-MM-DD)."""
return to_local(dt).strftime("%Y-%m-%d")
async def _query_measurements( async def _query_measurements(
db: AsyncSession, db: AsyncSession,
recipe_id: int, recipe_id: int,
@@ -323,9 +329,9 @@ async def generate_spc_report(
if version_id: if version_id:
filters_desc.append(f"Version: {version_id}") filters_desc.append(f"Version: {version_id}")
if date_from: if date_from:
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}") filters_desc.append(f"From: {_giorno_locale(date_from)}")
if date_to: if date_to:
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}") filters_desc.append(f"To: {_giorno_locale(date_to)}")
if lot_number: if lot_number:
filters_desc.append(f"Lot: {lot_number}") filters_desc.append(f"Lot: {lot_number}")
if serial_number: if serial_number:
@@ -342,7 +348,7 @@ async def generate_spc_report(
control_chart_svg=control_chart_svg, control_chart_svg=control_chart_svg,
histogram_svg=histogram_svg, histogram_svg=histogram_svg,
filters_desc=filters_desc, filters_desc=filters_desc,
generated_at=datetime.now(), generated_at=to_local(utcnow()),
n_measurements=len(measurements), n_measurements=len(measurements),
) )
@@ -395,7 +401,7 @@ async def generate_measurement_report(
"value": f"{float(m.value):.4f}", "value": f"{float(m.value):.4f}",
"unit": st_info.get("unit", "mm"), "unit": st_info.get("unit", "mm"),
"pass_fail": m.pass_fail, "pass_fail": m.pass_fail,
"measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"), "measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"), "operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
"lot_number": m.lot_number or "—", "lot_number": m.lot_number or "—",
"serial_number": m.serial_number or "—", "serial_number": m.serial_number or "—",
@@ -414,9 +420,9 @@ async def generate_measurement_report(
if version_id: if version_id:
filters_desc.append(f"Version: {version_id}") filters_desc.append(f"Version: {version_id}")
if date_from: if date_from:
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}") filters_desc.append(f"From: {_giorno_locale(date_from)}")
if date_to: if date_to:
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}") filters_desc.append(f"To: {_giorno_locale(date_to)}")
if lot_number: if lot_number:
filters_desc.append(f"Lot: {lot_number}") filters_desc.append(f"Lot: {lot_number}")
if serial_number: if serial_number:
@@ -430,7 +436,7 @@ async def generate_measurement_report(
rows=rows, rows=rows,
summary=summary, summary=summary,
filters_desc=filters_desc, filters_desc=filters_desc,
generated_at=datetime.now(), generated_at=to_local(utcnow()),
n_measurements=len(measurements), n_measurements=len(measurements),
) )
+214 -7
View File
@@ -5,6 +5,7 @@ to the worker over HTTP and stays ignorant of how a number was produced.
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import math import math
from pathlib import Path from pathlib import Path
@@ -46,7 +47,13 @@ def _is_measurable(value: object) -> bool:
) )
async def _call_worker(image_bytes: bytes, graph: dict) -> dict: async def _call_worker(
image_bytes: bytes,
graph: dict,
*,
recipe_dir: str | None = None,
calibration: dict | None = None,
) -> 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.
The worker's fault must not become the API's fault (spec's edge-case The worker's fault must not become the API's fault (spec's edge-case
@@ -60,12 +67,19 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
is not the worker's JSON contract must not turn into a bare KeyError is not the worker's JSON contract must not turn into a bare KeyError
further down. further down.
""" """
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
# si mandano, il worker usa i suoi default.
data = {"graph": json.dumps(graph)}
if recipe_dir:
data["recipe_dir"] = recipe_dir
if calibration:
data["calibration"] = json.dumps(calibration)
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"{settings.vision_worker_url}/run",
files={"image": ("frame.png", image_bytes, "image/png")}, files={"image": ("frame.png", image_bytes, "image/png")},
data={"graph": json.dumps(graph)}, data=data,
) )
except httpx.TimeoutException as exc: except httpx.TimeoutException as exc:
raise HTTPException( raise HTTPException(
@@ -122,6 +136,188 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
return report return report
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
Returns {"ok", "errors", "outputs"}. A worker 422 (e.g. recipe_dir
refused) is folded into ok=False; transport faults become 502/504 as in
`_call_worker`. Replaced by the tests.
"""
data = {"graph": json.dumps(graph)}
if recipe_dir:
data["recipe_dir"] = recipe_dir
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{settings.vision_worker_url}/validate", data=data,
)
except httpx.TimeoutException as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"the vision worker did not answer in time: {exc}",
) from exc
except httpx.HTTPError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc}",
) from exc
if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT:
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text
errors = detail if isinstance(detail, list) else [str(detail)]
return {"ok": False, "errors": [str(e) for e in errors], "outputs": []}
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker refused the job: {response.text}",
)
try:
body = response.json()
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: not valid JSON",
) from exc
if not isinstance(body, dict) or "ok" not in body:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: missing ok",
)
body.setdefault("errors", [])
body.setdefault("outputs", [])
return body
# Task types that run a vision graph (#46).
VISION_TASK_TYPES = ("camera_measure", "dxf_compare")
def _safe_join(root: Path, relative: str) -> Path:
"""root/relative, refusing absolute paths, '..' and anything leaving root."""
candidate = Path(relative)
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
raise ValueError(f"unsafe path: {relative}")
full = (root / candidate).resolve()
if not full.is_relative_to(root.resolve()):
raise ValueError(f"unsafe path: {relative}")
return full
def collect_meta_paths(node: object) -> list[str]:
"""Every `path` string found in any dict under the recipe's `meta`."""
found: list[str] = []
if isinstance(node, dict):
for key, value in node.items():
if key == "path" and isinstance(value, str):
found.append(value)
else:
found.extend(collect_meta_paths(value))
elif isinstance(node, list):
for item in node:
found.extend(collect_meta_paths(item))
return found
def match_recipe_files(
recipe: dict, uploads: list[tuple[str, bytes]],
) -> tuple[dict[str, bytes], list[str]]:
"""Pair each uploaded file with a meta path by base name (#46).
Returns ({relative path: bytes}, errors). Errors list every upload that
has no name or matches no meta path, and every unsafe meta path. A meta
path left without a file is not an error here: /validate decides.
"""
paths = collect_meta_paths(recipe.get("meta", {}))
by_name: dict[str, str] = {}
for p in paths:
by_name.setdefault(Path(p.replace("\\", "/")).name, p)
placed: dict[str, bytes] = {}
errors: list[str] = []
for name, content in uploads:
if not name:
errors.append("a file has no name")
continue
target = by_name.get(Path(name.replace("\\", "/")).name)
if target is None:
errors.append(f"file {name!r} matches no path in the recipe's meta")
continue
try:
_safe_join(Path("."), target)
except ValueError:
errors.append(f"meta path {target!r} is not allowed")
continue
placed[target] = content
return placed, errors
def store_recipe_folder(
task_id: int, recipe: dict, files: dict[str, bytes],
) -> str:
"""Write the immutable recipe folder; return its path relative to uploads.
Folder name = first 16 hex of the recipe's sha256. If it already exists it
is left untouched (immutable: other task versions may reference it).
"""
canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":"))
# Il digest copre anche i file (path ordinato + sha256 del contenuto): la
# stessa ricetta con file diversi (es. primo upload senza il DXF) deve dare
# una cartella diversa, altrimenti quella incompleta resterebbe per sempre
# perche' immutabile (#46).
h = hashlib.sha256(canonical.encode("utf-8"))
for rel in sorted(files):
h.update(b"\0" + rel.encode("utf-8") + b"\0")
h.update(hashlib.sha256(files[rel]).digest())
digest = h.hexdigest()[:16]
relative = f"vision/recipes/{task_id}/{digest}"
folder = settings.upload_path / relative
if folder.exists():
return relative
folder.mkdir(parents=True, exist_ok=True)
(folder / "recipe.json").write_text(
json.dumps(recipe, indent=2, ensure_ascii=False), encoding="utf-8",
)
for rel, content in files.items():
target = _safe_join(folder, rel)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
return relative
def list_recipe_files(recipe_dir: str | None) -> list[str]:
"""Files of a recipe folder, relative to it (recipe.json excluded)."""
if not recipe_dir:
return []
try:
folder = _safe_join(settings.upload_path, recipe_dir)
except ValueError:
return []
if not folder.is_dir():
return []
return sorted(
p.relative_to(folder).as_posix()
for p in folder.rglob("*") if p.is_file() and p.name != "recipe.json"
)
def save_result_files(
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
) -> tuple[str, str]:
"""Save the executed image and its overlay JSON side by side (#46).
Returns (image_path, overlay_path), both relative to uploads.
"""
stem = uuid4().hex
base = Path("vision") / "results" / str(task_id)
(settings.upload_path / base).mkdir(parents=True, exist_ok=True)
image_rel = (base / f"{stem}.{image_ext}").as_posix()
overlay_rel = (base / f"{stem}.overlay.json").as_posix()
(settings.upload_path / image_rel).write_bytes(image_bytes)
(settings.upload_path / overlay_rel).write_text(json.dumps(overlay), encoding="utf-8")
return image_rel, overlay_rel
async def execute_task( async def execute_task(
db: AsyncSession, db: AsyncSession,
task: RecipeTask, task: RecipeTask,
@@ -132,8 +328,9 @@ async def execute_task(
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
production_run_id: int | None = None, production_run_id: int | None = None,
) -> tuple[VisionResult, list[Measurement]]: image_ext: str = "png",
if task.task_type != "camera_measure": ) -> tuple[VisionResult, list[Measurement], dict]:
if task.task_type not in VISION_TASK_TYPES:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"task type {task.task_type!r} is not a camera measurement", detail=f"task type {task.task_type!r} is not a camera measurement",
@@ -210,7 +407,11 @@ async def execute_task(
), ),
) )
report = await _call_worker(image_bytes, task.vision_json) # Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
# passa `calibration`, la ricetta porta la sua (#46).
report = await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
)
outputs = report["outputs"] outputs = report["outputs"]
# Every quote or none. A half-measured piece read as a whole one is worse # Every quote or none. A half-measured piece read as a whole one is worse
@@ -231,8 +432,12 @@ async def execute_task(
), ),
) )
overlay = report.get("overlay") or []
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
result = VisionResult( result = VisionResult(
task_id=task.id, task_id=task.id,
image_path=image_path,
overlay_path=overlay_path,
engine_version=report["engine_version"], engine_version=report["engine_version"],
executed_on="server", executed_on="server",
graph_snapshot=task.vision_json, graph_snapshot=task.vision_json,
@@ -257,7 +462,7 @@ async def execute_task(
enforce_tolerance_gate=False, enforce_tolerance_gate=False,
)) ))
return result, saved return result, saved, report
def _reference_file_path(reference: VisionReferenceImage) -> Path: def _reference_file_path(reference: VisionReferenceImage) -> Path:
@@ -377,4 +582,6 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="this task carries no vision graph", detail="this task carries no vision graph",
) )
return await _call_worker(image_bytes, task.vision_json) return await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
)
+4 -4
View File
@@ -40,7 +40,7 @@ from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.access_log import AccessLog from src.backend.models.orm.access_log import AccessLog
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
from src.backend.models.orm.station import Station, StationRecipeAssignment from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.services.auth_service import hash_password, generate_api_key from src.backend.services.auth_service import hash_password, issue_api_key
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# In-memory SQLite engine for tests # In-memory SQLite engine for tests
@@ -142,7 +142,6 @@ async def _create_user(
active: bool = True, active: bool = True,
) -> User: ) -> User:
"""Insert a user into the test DB and return it with an API key set.""" """Insert a user into the test DB and return it with an API key set."""
api_key = generate_api_key()
user = User( user = User(
username=username, username=username,
password_hash=hash_password(password), password_hash=hash_password(password),
@@ -150,13 +149,14 @@ async def _create_user(
roles=roles or [], roles=roles or [],
is_admin=is_admin, is_admin=is_admin,
active=active, active=active,
api_key=api_key,
language_pref="en", language_pref="en",
theme_pref="light", theme_pref="light",
) )
session.add(user) session.add(user)
await session.flush() await session.flush()
await session.refresh(user) await session.refresh(user)
# Clear key of this test session; the DB only keeps its hash.
user.plain_key = await issue_api_key(session, user)
return user return user
@@ -278,4 +278,4 @@ async def create_test_recipe(
def auth_headers(user: User) -> dict[str, str]: def auth_headers(user: User) -> dict[str, str]:
"""Return headers dict with the user's API key for authenticated requests.""" """Return headers dict with the user's API key for authenticated requests."""
return {"X-API-Key": user.api_key} return {"X-API-Key": user.plain_key}
+151 -46
View File
@@ -78,75 +78,114 @@ class TestLogin:
assert len(api_key) >= 32 assert len(api_key) >= 32
class TestLoginKeyReuse: class TestLoginKeyPerSession:
"""login_user reuses an existing api_key instead of rotating it (#25).""" """Each login issues its own key; none revokes another (#25, #41)."""
async def test_two_logins_same_user_share_api_key( async def test_two_logins_same_user_get_distinct_valid_keys(
self, client: AsyncClient, db_session self, client: AsyncClient, db_session
): ):
"""Two stations logging in as the same shift account both keep working."""
await _create_user(db_session, username="twostations", password="TwoStations1") await _create_user(db_session, username="twostations", password="TwoStations1")
resp1 = await client.post( creds = {"username": "twostations", "password": "TwoStations1"}
"/api/auth/login", resp1 = await client.post("/api/auth/login", json=creds)
json={"username": "twostations", "password": "TwoStations1"}, resp2 = await client.post("/api/auth/login", json=creds)
) k1, k2 = resp1.json()["api_key"], resp2.json()["api_key"]
resp2 = await client.post( assert k1 != k2
"/api/auth/login", for k in (k1, k2):
json={"username": "twostations", "password": "TwoStations1"}, resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
) assert resp.status_code == 200
assert resp1.status_code == 200
assert resp2.status_code == 200
assert resp1.json()["api_key"] == resp2.json()["api_key"]
async def test_login_generates_key_when_user_has_none( async def test_db_stores_hash_never_clear_key(
self, client: AsyncClient, db_session self, client: AsyncClient, db_session
): ):
"""A user created without an api_key (e.g. via create_user) still gets one.""" from sqlalchemy import select
from src.backend.services.auth_service import create_user from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import hash_api_key
await create_user( await _create_user(db_session, username="hashed", password="HashedPass1")
db_session, username="brandnew", password="BrandNew1",
display_name="Brand New",
)
resp = await client.post( resp = await client.post(
"/api/auth/login", "/api/auth/login", json={"username": "hashed", "password": "HashedPass1"}
json={"username": "brandnew", "password": "BrandNew1"},
) )
key = resp.json()["api_key"]
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
assert hash_api_key(key) in hashes
assert key not in hashes
assert all(len(h) == 64 for h in hashes)
async def test_expired_key_gives_401(self, client: AsyncClient, db_session):
from datetime import timedelta
from sqlalchemy import update
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import utcnow
user = await _create_user(db_session, username="expired", password="ExpiredPass1")
await db_session.execute(
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
)
resp = await client.get("/api/auth/me", headers=auth_headers(user))
assert resp.status_code == 401
assert "expired" in resp.json()["detail"].lower()
async def test_use_renews_expiry_only_after_interval(
self, client: AsyncClient, db_session
):
from datetime import timedelta
from sqlalchemy import select, update
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import utcnow
user = await _create_user(db_session, username="renew", password="RenewPass1")
headers = auth_headers(user)
old = utcnow() - timedelta(hours=10)
# Recent use: no write, expiry untouched.
recent = utcnow() - timedelta(minutes=1)
await db_session.execute(
update(ApiKey).values(expires_at=old + timedelta(hours=24), last_used_at=recent)
)
await client.get("/api/auth/me", headers=headers)
row = (await db_session.execute(select(ApiKey))).scalar_one()
await db_session.refresh(row)
assert row.last_used_at == recent
# Stale last use: expiry is pushed forward.
await db_session.execute(
update(ApiKey).values(last_used_at=utcnow() - timedelta(minutes=10))
)
before = utcnow()
resp = await client.get("/api/auth/me", headers=headers)
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["api_key"] await db_session.refresh(row)
assert row.expires_at >= before + timedelta(hours=23, minutes=59)
class TestLogout: class TestLogout:
"""POST /api/auth/logout is a no-op on the shared api_key by default (#25).""" """Logout deletes only this session's key (#41)."""
async def test_logout_default_keeps_api_key_valid( async def test_logout_removes_only_own_key(self, client: AsyncClient, db_session):
self, client: AsyncClient, db_session await _create_user(db_session, username="shared_logout", password="SharedPass1")
): creds = {"username": "shared_logout", "password": "SharedPass1"}
user = await _create_user( k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
db_session, username="shared_logout", password="SharedPass1" k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
) resp = await client.post("/api/auth/logout", headers={"X-API-Key": k1})
headers = auth_headers(user)
resp = await client.post("/api/auth/logout", headers=headers)
assert resp.status_code == 204 assert resp.status_code == 204
# The key is still valid: another station on the same account keeps working. assert (await client.get("/api/auth/me", headers={"X-API-Key": k1})).status_code == 401
resp = await client.get("/api/auth/me", headers=headers) assert (await client.get("/api/auth/me", headers={"X-API-Key": k2})).status_code == 200
assert resp.status_code == 200
async def test_logout_all_sessions_invalidates_api_key( async def test_logout_all_sessions_removes_every_key(
self, client: AsyncClient, db_session self, client: AsyncClient, db_session
): ):
user = await _create_user( await _create_user(db_session, username="shared_logout_all", password="SharedPass1")
db_session, username="shared_logout_all", password="SharedPass1" creds = {"username": "shared_logout_all", "password": "SharedPass1"}
) k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
headers = auth_headers(user) k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
resp = await client.post( resp = await client.post(
"/api/auth/logout", params={"all_sessions": "true"}, headers=headers "/api/auth/logout", params={"all_sessions": "true"},
headers={"X-API-Key": k1},
) )
assert resp.status_code == 204 assert resp.status_code == 204
for k in (k1, k2):
resp = await client.get("/api/auth/me", headers=headers) assert (await client.get("/api/auth/me", headers={"X-API-Key": k})).status_code == 401
assert resp.status_code == 401
class TestAuthenticateUserTiming: class TestAuthenticateUserTiming:
@@ -185,3 +224,69 @@ class TestHealthAndAuth:
resp = await client.get("/api/users") resp = await client.get("/api/users")
assert resp.status_code == 401 assert resp.status_code == 401
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"] assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
class TestRevokeAllKeys:
"""Password change, deactivation and regenerate invalidate every key (#41)."""
async def _two_keys(self, client, db_session, name):
user = await _create_user(db_session, username=name, password="KeysPass123")
creds = {"username": name, "password": "KeysPass123"}
keys = [
(await client.post("/api/auth/login", json=creds)).json()["api_key"]
for _ in range(2)
]
return user, keys
async def _all_401(self, client, keys):
for k in keys:
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
assert resp.status_code == 401
async def test_password_change_invalidates_all(self, client, db_session, admin_user):
user, keys = await self._two_keys(client, db_session, "pwkeys")
resp = await client.put(
f"/api/users/{user.id}/password",
headers=auth_headers(admin_user), json={"password": "NewSecurePass1"},
)
assert resp.status_code == 200
await self._all_401(client, keys)
async def test_deactivation_invalidates_all(self, client, db_session, admin_user):
user, keys = await self._two_keys(client, db_session, "offkeys")
resp = await client.delete(
f"/api/users/{user.id}", headers=auth_headers(admin_user)
)
assert resp.status_code == 204
await self._all_401(client, keys)
async def test_regenerate_invalidates_old_keys(self, client, db_session, admin_user):
user, keys = await self._two_keys(client, db_session, "regenkeys")
resp = await client.post(
f"/api/users/{user.id}/regenerate-key", headers=auth_headers(admin_user)
)
assert resp.status_code == 200
await self._all_401(client, keys)
new = resp.json()["api_key"]
assert (await client.get("/api/auth/me", headers={"X-API-Key": new})).status_code == 200
class TestExpiredKeyCleanup:
async def test_expired_key_removed_at_next_login(self, client, db_session):
from datetime import timedelta
from sqlalchemy import select, update
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import hash_api_key, utcnow
user = await _create_user(db_session, username="cleanup", password="CleanPass123")
old_hash = hash_api_key(user.plain_key)
await db_session.execute(
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
)
resp = await client.post(
"/api/auth/login", json={"username": "cleanup", "password": "CleanPass123"}
)
assert resp.status_code == 200
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
assert old_hash not in hashes
assert len(hashes) == 1
+172
View File
@@ -0,0 +1,172 @@
"""#40: after a reload, the quotes already measured in the cycle come back.
The cycle has no column of its own: it opens at the latest start, cycle_completed
or remeasure-of-this-task event of the run, and a task_measured of the task after
that boundary means the task is closed in this cycle.
"""
from datetime import datetime, timedelta
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionEvent, ProductionRun
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.tests.conftest import auth_headers, create_test_recipe
T0 = datetime(2026, 10, 1, 8, 0, 0)
async def _setup(db, user):
recipe = await create_test_recipe(db, user.id)
version = (await db.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
task = (await db.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalars().first()
first = (await db.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
second = RecipeSubtask(
task_id=task.id, marker_number=2, description="Quota 2",
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
)
db.add(second)
station = Station(code="ST-CYC", name="St", active=True, created_by=user.id)
db.add(station)
await db.commit()
run = ProductionRun(
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
operator_id=user.id, status="running", active_station_id=station.id,
)
db.add(run)
await db.commit()
return run, version, task, first, second
async def _event(db, run, user, kind, minutes, task_id=None):
db.add(ProductionEvent(
run_id=run.id, event_type=kind, user_id=user.id, task_id=task_id,
created_at=T0 + timedelta(minutes=minutes),
))
await db.commit()
async def _meas(db, run, version, subtask, user, minutes, value=10.0):
db.add(Measurement(
subtask_id=subtask.id, version_id=version.id, measured_by=user.id,
value=value, pass_fail="pass", production_run_id=run.id,
measured_at=T0 + timedelta(minutes=minutes),
))
await db.commit()
async def _get(client, user, run, task):
resp = await client.get(
"/api/measurements/current-cycle", headers=auth_headers(user),
params={"production_run_id": run.id, "task_id": task.id},
)
assert resp.status_code == 200
return resp.json()["measurements"]
async def test_only_second_cycle_after_cycle_completed(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1, 10.1)
await _meas(db_session, run, version, q2, u, 2, 10.2)
await _event(db_session, run, u, "cycle_completed", 3)
await _meas(db_session, run, version, q1, u, 4, 10.3)
rows = await _get(client, u, run, task)
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.3)]
assert rows[0]["pass_fail"] == "pass"
async def test_last_reading_of_a_quote_wins(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1, 10.1)
await _meas(db_session, run, version, q1, u, 2, 10.4)
rows = await _get(client, u, run, task)
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.4)]
async def test_remeasure_of_the_task_empties_the_list(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1)
await _event(db_session, run, u, "remeasure", 2, task_id=task.id)
assert await _get(client, u, run, task) == []
async def test_remeasure_of_another_task_does_not_move_the_boundary(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
other = RecipeTask(
version_id=version.id, title="Altro", task_type=task.task_type, order_index=9,
)
db_session.add(other)
await db_session.commit()
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1)
await _event(db_session, run, u, "remeasure", 2, task_id=other.id)
assert len(await _get(client, u, run, task)) == 1
async def test_task_measured_after_boundary_means_closed(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1)
await _meas(db_session, run, version, q2, u, 2)
await _event(db_session, run, u, "task_measured", 3, task_id=task.id)
assert await _get(client, u, run, task) == []
async def test_task_measured_before_boundary_does_not_close_next_cycle(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _event(db_session, run, u, "task_measured", 1, task_id=task.id)
await _event(db_session, run, u, "cycle_completed", 2)
await _meas(db_session, run, version, q1, u, 3)
assert len(await _get(client, u, run, task)) == 1
async def test_permissions(client: AsyncClient, db_session, measurement_tec_user, maker_user):
run, _v, task, _q1, _q2 = await _setup(db_session, measurement_tec_user)
params = {"production_run_id": run.id, "task_id": task.id}
no_key = await client.get("/api/measurements/current-cycle", params=params)
assert no_key.status_code in (401, 403)
forbidden = await client.get(
"/api/measurements/current-cycle", headers=auth_headers(maker_user), params=params,
)
assert forbidden.status_code == 403
+42
View File
@@ -107,3 +107,45 @@ class TestBaseMigrationNoopsOnExistingSchema:
assert "image_path" in { assert "image_path" in {
c["name"] for c in insp.get_columns("recipes") c["name"] for c in insp.get_columns("recipes")
} }
class TestApiKeyHashMigration:
"""015_api_key_hash: users.api_key -> api_keys table, and back (#41)."""
def test_015_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
command.upgrade(cfg, "head")
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")}
assert {c["name"] for c in insp.get_columns("api_keys")} >= {
"user_id", "key_hash", "created_at", "expires_at", "last_used_at",
}
command.downgrade(cfg, "014_vision_fk_ondelete")
insp = _inspect(db_path)
assert "api_keys" not in insp.get_table_names()
assert "api_key" in {c["name"] for c in insp.get_columns("users")}
command.upgrade(cfg, "head") # and up again
assert "api_keys" in _inspect(db_path).get_table_names()
class TestVisionRecipeDirMigration:
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
def test_016_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def cols():
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
command.upgrade(cfg, "head")
assert "vision_recipe_dir" in cols()
command.downgrade(cfg, "015_api_key_hash")
assert "vision_recipe_dir" not in cols()
command.upgrade(cfg, "head") # e di nuovo su
assert "vision_recipe_dir" in cols()
+113
View File
@@ -0,0 +1,113 @@
"""UTC in the DB, explicit +00:00 on the wire, local time only on presentation (#42)."""
from datetime import datetime, timezone
from unittest.mock import patch
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.user import User
from src.backend.services import report_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.utils.tempo import as_utc_naive, to_local, utcnow
def test_utcnow_naive_and_close_to_utc():
now = utcnow()
assert now.tzinfo is None
delta = datetime.now(timezone.utc).replace(tzinfo=None) - now
assert abs(delta.total_seconds()) < 5
def test_as_utc_naive():
parsed = datetime.fromisoformat("2026-10-06T10:00:00+02:00")
assert as_utc_naive(parsed) == datetime(2026, 10, 6, 8, 0)
assert as_utc_naive(parsed).tzinfo is None
naive = datetime(2026, 10, 6, 8, 0)
assert as_utc_naive(naive) is naive
assert as_utc_naive(None) is None
def test_to_local_is_rome():
assert to_local(datetime(2026, 10, 6, 8, 0)).strftime("%H:%M %z") == "10:00 +0200"
assert to_local(datetime(2026, 1, 6, 8, 0)).strftime("%H:%M %z") == "09:00 +0100"
async def _make_measurement(client, user, recipe_id) -> None:
tasks = (await client.get(f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user))).json()
await client.post(
"/api/measurements/",
headers=auth_headers(user),
json={
"subtask_id": tasks[0]["subtasks"][0]["id"],
"version_id": tasks[0]["version_id"],
"value": 10.0,
},
)
async def test_api_datetimes_carry_utc_offset_and_filter_equivalence(
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
await _make_measurement(client, measurement_tec_user, recipe.id)
resp = await client.get("/api/measurements/", headers=auth_headers(metrologist_user))
item = resp.json()["items"][0]
assert item["measured_at"].endswith("+00:00")
# Same instant written with +02:00 and as UTC: identical results.
future_utc = "2099-01-01T08:00:00+00:00"
future_rome = "2099-01-01T10:00:00+02:00"
past_utc = "2000-01-01T08:00:00+00:00"
past_rome = "2000-01-01T10:00:00+02:00"
for frm, to in ((past_utc, future_utc), (past_rome, future_rome)):
r = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
params={"date_from": frm, "date_to": to},
)
assert r.status_code == 200
assert r.json()["total"] == 1
# Boundary: measurement is "now" in UTC; a date_from 1h ahead in UTC,
# written as +02:00 (now+3h local wall clock), must exclude it.
ahead = (utcnow().replace(microsecond=0)).replace(tzinfo=timezone.utc)
from datetime import timedelta
ahead_utc = (ahead + timedelta(hours=1)).isoformat()
ahead_rome = (ahead + timedelta(hours=1)).astimezone(
timezone(timedelta(hours=2))
).isoformat()
totals = []
for frm in (ahead_utc, ahead_rome):
r = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
params={"date_from": frm},
)
totals.append(r.json()["total"])
assert totals == [0, 0]
async def test_report_generated_at_is_local_time(
db_session: AsyncSession, metrologist_user: User
):
recipe = await create_test_recipe(db_session, metrologist_user.id)
captured: dict[str, str] = {}
def fake_render(html: str) -> bytes:
captured["html"] = html
return b"%PDF"
with patch.object(report_service, "utcnow", return_value=datetime(2026, 10, 6, 8, 0)), \
patch.object(report_service, "_render_pdf", side_effect=fake_render):
await report_service.generate_measurement_report(db_session, recipe.id)
assert "Generated 2026-10-06 10:00" in captured["html"]
def test_report_filter_day_is_local():
"""UTC naive 22:00 of Oct 5 is already Oct 6 in Europe/Rome (#42)."""
assert report_service._giorno_locale(datetime(2026, 10, 5, 22, 0, 0)) == "2026-10-06"
@@ -17,7 +17,7 @@ class TestChangeUserPassword:
the same as setup.py's change-user-password (setup.py:634). the same as setup.py's change-user-password (setup.py:634).
""" """
old_key = maker_user.api_key old_key = maker_user.plain_key
old_username = maker_user.username old_username = maker_user.username
assert old_key is not None assert old_key is not None
@@ -31,7 +31,7 @@ class TestChangeUserPassword:
# The old key no longer authenticates. Read it before the request # The old key no longer authenticates. Read it before the request
# above: the client and app share one session (see conftest), so # above: the client and app share one session (see conftest), so
# `maker_user.api_key` in this process reflects the mutation too. # `maker_user.plain_key` in this process reflects the mutation too.
resp2 = await client.get("/api/auth/me", headers={"X-API-Key": old_key}) resp2 = await client.get("/api/auth/me", headers={"X-API-Key": old_key})
assert resp2.status_code == 401 assert resp2.status_code == 401
+2 -2
View File
@@ -66,7 +66,7 @@ async def two_vision_quotes(db_session, recipe_id: int):
def worker_says(monkeypatch): def worker_says(monkeypatch):
"""The worker does not run in tests: its response is faked here.""" """The worker does not run in tests: its response is faked here."""
def _install(outputs: dict, failures: list | None = None): def _install(outputs: dict, failures: list | None = None):
async def fake_call(image_bytes: bytes, graph: dict) -> dict: async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
return { return {
"outputs": outputs, "outputs": outputs,
"failures": failures or [], "failures": failures or [],
@@ -316,7 +316,7 @@ async def test_a_pending_fail_blocks_before_the_worker_is_called(
) )
await db_session.commit() await db_session.commit()
async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict: async def worker_must_not_be_called(image_bytes: bytes, graph: dict, **kwargs) -> dict:
raise AssertionError("the worker must not run while the operator is blocked") raise AssertionError("the worker must not run while the operator is blocked")
monkeypatch.setattr( monkeypatch.setattr(
"src.backend.services.vision_service._call_worker", worker_must_not_be_called, "src.backend.services.vision_service._call_worker", worker_must_not_be_called,
@@ -53,7 +53,7 @@ async def a_camera_task(db_session, recipe_id: int):
@pytest.fixture @pytest.fixture
def worker_says(monkeypatch): def worker_says(monkeypatch):
def _install(outputs: dict): def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict: async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
return {"outputs": outputs, "failures": [], return {"outputs": outputs, "failures": [],
"engine_version": "e" * 40, "duration_ms": 3} "engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr( monkeypatch.setattr(
@@ -0,0 +1,282 @@
"""Ricetta VisionSuite v2 su un task, execute dxf_compare, copy-on-write (#46)."""
import io
import json
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionResult
from src.backend.tests.conftest import auth_headers, create_test_recipe
RECIPE = {
"schema_version": 2, "name": "dxf", "tools": [],
"meta": {"dxf": {"d1": {"path": "dxf/part.dxf"}}, "models": {"m": {"path": "models/m.npz"}}},
}
OUTPUTS = [{"key": "cmp.points_in_pct", "quantity": "ratio", "unit": "%"}]
GOOD_FILES = (("part.dxf", b"0 EOF"), ("m.npz", b"npz"))
@pytest.fixture(autouse=True)
def redirect_uploads(monkeypatch, tmp_path):
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
@pytest.fixture
def validate_says(monkeypatch):
"""Il worker /validate non gira nei test: risposta finta."""
def _install(ok=True, errors=None, outputs=None):
async def fake(graph, recipe_dir):
return {
"ok": ok, "errors": errors or [],
"outputs": outputs if outputs is not None else OUTPUTS,
}
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
return _install
def an_image() -> bytes:
buf = io.BytesIO()
Image.new("L", (32, 32)).save(buf, format="PNG")
return buf.getvalue()
async def make_task(db_session, user, task_type="dxf_compare"):
recipe = await create_test_recipe(db_session, user.id)
version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().first()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalars().first()
subtask = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
task.task_type = task_type
await db_session.commit()
return task, subtask
def upload(client, user, task, recipe=RECIPE, files=()):
return client.post(
f"/api/vision/tasks/{task.id}/recipe",
headers=auth_headers(user),
files=[("recipe", ("r.json", json.dumps(recipe).encode(), "application/json"))]
+ [("files", (n, c, "application/octet-stream")) for n, c in files],
)
@pytest.mark.asyncio
async def test_upload_ok_stores_folder_and_task(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
resp = await upload(client, maker_user, task, files=GOOD_FILES)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["outputs"] == OUTPUTS
assert sorted(body["files"]) == ["dxf/part.dxf", "models/m.npz"]
assert body["recipe_dir"].startswith(f"vision/recipes/{task.id}/")
folder = settings.upload_path / body["recipe_dir"]
assert (folder / "recipe.json").is_file() and (folder / "dxf" / "part.dxf").is_file()
await db_session.refresh(task)
assert task.vision_json == RECIPE and task.vision_recipe_dir == body["recipe_dir"]
got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user))
assert got.status_code == 200
assert got.json()["recipe"] == RECIPE and got.json()["outputs"] == OUTPUTS
@pytest.mark.asyncio
async def test_get_without_recipe_is_empty(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user))
assert got.json() == {
"task_id": task.id, "recipe_dir": None, "recipe": None, "files": [], "outputs": [],
}
@pytest.mark.asyncio
async def test_unmatched_file_name_is_422_list(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
resp = await upload(client, maker_user, task, files=[("other.dxf", b"x")])
assert resp.status_code == 422
assert isinstance(resp.json()["detail"], list) and "other.dxf" in resp.json()["detail"][0]
await db_session.refresh(task)
assert task.vision_json is None and task.vision_recipe_dir is None
@pytest.mark.asyncio
async def test_traversal_in_meta_path_is_422(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
evil = {"schema_version": 2, "tools": [], "meta": {"dxf": {"d": {"path": "../../evil.dxf"}}}}
resp = await upload(client, maker_user, task, recipe=evil, files=[("evil.dxf", b"x")])
assert resp.status_code == 422
assert not (settings.upload_path.parent / "evil.dxf").exists()
@pytest.mark.asyncio
async def test_wrong_task_type_is_422(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user, task_type="measure")
resp = await upload(client, maker_user, task, files=GOOD_FILES)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_invalid_json_is_422(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
resp = await client.post(
f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user),
files=[("recipe", ("r.json", b"{nope", "application/json"))],
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_worker_not_ok_is_422_and_task_unchanged(client, db_session, maker_user, validate_says):
validate_says(ok=False, errors=["meta file missing"])
task, _ = await make_task(db_session, maker_user)
task.vision_json = {"old": 1}
task.vision_recipe_dir = "vision/recipes/old"
await db_session.commit()
resp = await upload(client, maker_user, task, files=GOOD_FILES)
assert resp.status_code == 422
assert resp.json()["detail"] == ["meta file missing"]
await db_session.refresh(task)
assert task.vision_json == {"old": 1} and task.vision_recipe_dir == "vision/recipes/old"
@pytest.mark.asyncio
async def test_existing_folder_is_not_rewritten(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
first = (await upload(client, maker_user, task, files=GOOD_FILES)).json()
marker = settings.upload_path / first["recipe_dir"] / "dxf" / "part.dxf"
marker.write_bytes(b"CHANGED")
second = (await upload(client, maker_user, task, files=GOOD_FILES)).json()
assert second["recipe_dir"] == first["recipe_dir"]
assert marker.read_bytes() == b"CHANGED"
@pytest.mark.asyncio
async def test_upload_requires_maker(
client, db_session, maker_user, measurement_tec_user, validate_says,
):
validate_says()
task, _ = await make_task(db_session, maker_user)
resp = await upload(client, measurement_tec_user, task, files=GOOD_FILES)
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
async def test_execute_dxf_compare_saves_files_and_extended_response(
client, db_session, measurement_tec_user, monkeypatch,
):
task, subtask = await make_task(db_session, measurement_tec_user)
task.vision_json = RECIPE
task.vision_recipe_dir = "vision/recipes/1/abc"
subtask.vision_output = "cmp.points_in_pct"
await db_session.commit()
seen = {}
overlay = [{"type": "contour", "role": "found", "rows": [1], "cols": [2], "tool_id": "cmp"}]
async def fake_call(image_bytes, graph, **kwargs):
seen.update(kwargs)
return {
"outputs": {"cmp.points_in_pct": float(subtask.nominal)},
"failures": [], "engine_version": "e" * 40, "duration_ms": 5,
"overlay": overlay, "outcome": "OK",
}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await client.post(
"/api/vision/execute", headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert seen["recipe_dir"] == "vision/recipes/1/abc" and "calibration" not in seen
assert body["outcome"] == "OK" and body["overlay"] == overlay and body["failures"] == []
assert body["image_url"] == f"/api/vision/results/{body['vision_result_id']}/image"
assert body["measurements"][0]["pass_fail"] == "pass"
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert (settings.upload_path / result.image_path).read_bytes() == an_image()
assert json.loads((settings.upload_path / result.overlay_path).read_text()) == overlay
img = await client.get(body["image_url"], headers=auth_headers(measurement_tec_user))
assert img.status_code == 200 and img.content == an_image()
@pytest.mark.asyncio
async def test_execute_dxf_compare_out_of_tolerance_fails(
client, db_session, measurement_tec_user, monkeypatch,
):
task, subtask = await make_task(db_session, measurement_tec_user)
task.vision_json = RECIPE
subtask.vision_output = "cmp.points_in_pct"
await db_session.commit()
async def fake_call(image_bytes, graph, **kwargs):
return {"outputs": {"cmp.points_in_pct": 99.0}, "failures": [],
"engine_version": "e" * 40, "outcome": "OK"}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await client.post(
"/api/vision/execute", headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert resp.status_code == 200
# il verdetto e' delle tolleranze del subtask, non di outcome di VisionSuite
assert resp.json()["measurements"][0]["pass_fail"] == "fail"
assert resp.json()["overlay"] == []
@pytest.mark.asyncio
async def test_copy_on_write_carries_recipe_dir(client, db_session, maker_user):
from src.backend.tests.test_vision_copy_on_write import (
_current_task_and_subtask, _seed_measurement,
)
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
task.task_type = "dxf_compare"
task.vision_json = RECIPE
task.vision_recipe_dir = "vision/recipes/1/abc"
await db_session.flush()
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
resp = await client.put(
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user),
json={"change_notes": "cow"},
)
assert resp.status_code == 200
db_session.expunge_all()
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
assert version.version_number == 2
assert new_task.vision_recipe_dir == "vision/recipes/1/abc" and new_task.vision_json == RECIPE
@pytest.mark.asyncio
async def test_retry_with_missing_file_then_complete_succeeds(
client, db_session, maker_user, monkeypatch,
):
# worker finto: ok solo se il DXF esiste nella cartella della ricetta
async def fake(graph, recipe_dir):
present = (settings.upload_path / recipe_dir / "dxf" / "part.dxf").is_file()
return {"ok": present, "errors": [] if present else ["dxf missing"], "outputs": OUTPUTS}
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
task, _ = await make_task(db_session, maker_user)
first = await upload(client, maker_user, task, files=[])
assert first.status_code == 422
second = await upload(client, maker_user, task, files=GOOD_FILES)
assert second.status_code == 200, second.text
+1 -1
View File
@@ -39,7 +39,7 @@ async def recipe_parts(db_session, recipe_id: int):
@pytest.fixture @pytest.fixture
def worker_says(monkeypatch): def worker_says(monkeypatch):
def _install(outputs: dict): def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict: async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
return {"outputs": outputs, "failures": [], return {"outputs": outputs, "failures": [],
"engine_version": "b" * 40, "duration_ms": 7} "engine_version": "b" * 40, "duration_ms": 7}
monkeypatch.setattr( monkeypatch.setattr(
@@ -69,7 +69,7 @@ async def recipe_parts(db_session, recipe_id: int):
@pytest.fixture @pytest.fixture
def worker_says(monkeypatch): def worker_says(monkeypatch):
def _install(outputs: dict): def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict: async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
return {"outputs": outputs, "failures": [], return {"outputs": outputs, "failures": [],
"engine_version": "c" * 40, "duration_ms": 5} "engine_version": "c" * 40, "duration_ms": 5}
monkeypatch.setattr( monkeypatch.setattr(
View File
+34
View File
@@ -0,0 +1,34 @@
"""Time helpers: the DB holds naive UTC, presentation converts to display_timezone (#42)."""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from src.backend.config import settings
def utcnow() -> datetime:
"""Naive UTC now (the whole DB moves to naive UTC, #42)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def as_utc_naive(dt: datetime | None) -> datetime | None:
"""Aware -> UTC naive; naive is taken as UTC already; None stays None."""
if dt is None or dt.tzinfo is None:
return dt
return dt.astimezone(timezone.utc).replace(tzinfo=None)
def to_utc_aware(dt: datetime) -> datetime:
"""Naive (UTC) or aware datetime -> aware UTC."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def iso_utc(dt: datetime | None) -> str:
"""ISO 8601 with explicit +00:00; "" for None (CSV cells)."""
return to_utc_aware(dt).isoformat() if dt else ""
def to_local(dt: datetime) -> datetime:
"""Naive UTC (or aware) datetime -> aware datetime in settings.display_timezone."""
return to_utc_aware(dt).astimezone(ZoneInfo(settings.display_timezone))
+22 -1
View File
@@ -2,7 +2,8 @@
import json import json
import os import os
import re import re
from datetime import date from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from urllib.parse import urlparse from urllib.parse import urlparse
from flask import Flask, flash, g, jsonify, redirect, url_for, session, request from flask import Flask, flash, g, jsonify, redirect, url_for, session, request
@@ -154,6 +155,26 @@ def create_app() -> Flask:
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped) bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
return Markup(bolded.replace("\n", "<br>")) return Markup(bolded.replace("\n", "<br>"))
@app.template_filter("local_dt")
def local_dt_filter(value, fmt="%d/%m/%Y %H:%M"):
"""Show a UTC datetime (ISO string or datetime) in DISPLAY_TIMEZONE.
A value without timezone is taken as UTC. None or empty gives "-".
"""
if value is None or value == "":
return "-"
if isinstance(value, str):
try:
value = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return value
if not isinstance(value, datetime):
return str(value)
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
tz = ZoneInfo(app.config.get("DISPLAY_TIMEZONE") or "Europe/Rome")
return value.astimezone(tz).strftime(fmt)
@app.template_filter("tojson_attr") @app.template_filter("tojson_attr")
def tojson_attr_filter(value): def tojson_attr_filter(value):
"""JSON encode safe for HTML attributes (x-data, etc.). """JSON encode safe for HTML attributes (x-data, etc.).
@@ -595,6 +595,40 @@ def api_task_progress(task_id: int):
return jsonify(row), 200 return jsonify(row), 200
# ---------------------------------------------------------------------------
# Route: quotes already measured in the current cycle (#40)
# ---------------------------------------------------------------------------
@measure_bp.route("/api/current-cycle/<int:task_id>", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_current_cycle(task_id: int):
"""Proxy: quotes of this task already measured in the cycle under way.
The run comes from _current_run_id(), like the rest of the page. Outside a
production there is no cycle to restore and the answer is an empty list.
"""
task_resp = api_client.get(f"/api/tasks/{task_id}")
if isinstance(task_resp, dict) and task_resp.get("error"):
return jsonify(task_resp), task_resp.get("status_code", 500)
recipe_id = task_resp.get("recipe_id")
if recipe_id and not _recipe_allowed_at_station(recipe_id):
abort(403, description=_("Ricetta non assegnata a questa stazione"))
run_id = _current_run_id()
if not run_id:
return jsonify({"measurements": []}), 200
resp = api_client.get(
"/api/measurements/current-cycle",
params={"production_run_id": run_id, "task_id": task_id},
)
if not isinstance(resp, dict) or resp.get("error"):
# Never fatal: without the restore the screen behaves as it always did.
return jsonify({"measurements": []}), 200
return jsonify(resp), 200
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes: Out-of-tolerance authorisation (point 5) # Routes: Out-of-tolerance authorisation (point 5)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2
View File
@@ -56,6 +56,8 @@ class Config:
# Babel i18n # Babel i18n
BABEL_DEFAULT_LOCALE = "it" BABEL_DEFAULT_LOCALE = "it"
BABEL_DEFAULT_TIMEZONE = "Europe/Rome" BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
# Il DB e le API parlano UTC; si converte solo alla presentazione.
DISPLAY_TIMEZONE: str = os.getenv("DISPLAY_TIMEZONE", "Europe/Rome")
LANGUAGES = {"it": "Italiano", "en": "English"} LANGUAGES = {"it": "Italiano", "en": "English"}
@@ -271,7 +271,7 @@ function csvExport() {
*/ */
generateFilename(prefix) { generateFilename(prefix) {
const now = new Date(); const now = new Date();
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // YYYY-MM-DD locale
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
return `${prefix}_${dateStr}_${timeStr}.csv`; return `${prefix}_${dateStr}_${timeStr}.csv`;
@@ -183,7 +183,7 @@
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %} {% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
<tr> <tr>
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);"> <td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }} {{ m.measured_at|local_dt }}
</td> </td>
<td style="color: var(--text-secondary);" class="text-sm"> <td style="color: var(--text-secondary);" class="text-sm">
{{ m.task_info.title if m.task_info else '-' }} {{ m.task_info.title if m.task_info else '-' }}
@@ -1100,6 +1100,7 @@ function taskExecute() {
this.inputStartedAt = Date.now(); this.inputStartedAt = Date.now();
// Rejoin whatever production is already open at this station. // Rejoin whatever production is already open at this station.
await this.loadProductionRun(); await this.loadProductionRun();
await this.loadCurrentCycle();
await this.loadPendingAuthorisation(); await this.loadPendingAuthorisation();
// A tablet left to sleep drifts (#37.7): catch the countdown back up to // A tablet left to sleep drifts (#37.7): catch the countdown back up to
// the server's own clock, the same source adoptProductionRun already // the server's own clock, the same source adoptProductionRun already
@@ -1110,6 +1111,42 @@ function taskExecute() {
}); });
}, },
/* Quotes already measured in the cycle under way (#40).
A reload, a timer redirect or an auto-logout used to bring every quote back
and measuring them again put duplicates in the SPC statistics. Only inside
a production: outside one there is no cycle, and the screen starts empty as
it always did. */
async loadCurrentCycle() {
if (!this.isMeasureTask || !this.subtasks.length || !this.productionRun) return;
try {
const resp = await fetch(
'{{ url_for("measure.api_current_cycle", task_id=task.id) }}',
);
if (!resp.ok) return;
const data = await resp.json();
const known = new Set(this.subtasks.map(s => s.id));
const restored = (data.measurements || []).filter(m => known.has(m.subtask_id));
if (!restored.length) return;
this.measurements = restored.map(m => {
const st = this.subtasks.find(s => s.id === m.subtask_id);
const nominal = st ? (st.nominal || 0) : 0;
return {
subtask_id: m.subtask_id,
value: m.value,
pass_fail: m.pass_fail,
deviation: m.value - nominal,
};
});
const next = this.subtasks.findIndex(
s => !this.measurements.some(m => m.subtask_id === s.id),
);
this.currentIndex = next !== -1 ? next : this.subtasks.length - 1;
} catch (e) {
// Offline: the screen starts empty, as before.
}
},
/* Is a quote already waiting for the capoturno? /* Is a quote already waiting for the capoturno?
Reloading the page was the simplest way past the old gate. It is not one Reloading the page was the simplest way past the old gate. It is not one
@@ -312,8 +312,8 @@ function spcDashboard() {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId); params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId); if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00'); if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59'); if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
try { try {
// Parallel fetch of all endpoints // Parallel fetch of all endpoints
@@ -417,8 +417,8 @@ function spcDashboard() {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId); params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId); if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00'); if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59'); if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
const url = '/statistics/api/report-' + type + '?' + params.toString(); const url = '/statistics/api/report-' + type + '?' + params.toString();
const resp = await fetch(url); const resp = await fetch(url);
@@ -0,0 +1,31 @@
"""Filtro local_dt: il backend parla UTC, la presentazione e' in Europe/Rome (#42)."""
from datetime import datetime, timezone
import pytest
@pytest.fixture
def local_dt(flask_app):
return flask_app.jinja_env.filters["local_dt"]
def test_summer_time(local_dt):
assert local_dt("2026-10-06T08:00:00+00:00") == "06/10/2026 10:00"
def test_winter_time(local_dt):
assert local_dt("2026-01-15T08:00:00+00:00") == "15/01/2026 09:00"
def test_naive_is_utc(local_dt):
assert local_dt("2026-10-06T08:00:00") == "06/10/2026 10:00"
def test_datetime_object_and_format(local_dt):
dt = datetime(2026, 10, 6, 8, 0, tzinfo=timezone.utc)
assert local_dt(dt, "%H:%M") == "10:00"
@pytest.mark.parametrize("empty", [None, ""])
def test_empty(local_dt, empty):
assert local_dt(empty) == "-"
@@ -248,6 +248,67 @@ class TestApiTaskProgress:
assert resp.status_code in (302, 401) assert resp.status_code in (302, 401)
class TestApiCurrentCycle:
"""GET /measure/api/current-cycle/<task_id> (gitea#40)."""
@pytest.fixture(autouse=True)
def _station_configured(self, monkeypatch):
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
def test_forwards_run_and_task(self, logged_in_client, mock_api_client):
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
payload = {"measurements": [
{"subtask_id": 3, "value": 10.1, "pass_fail": "pass",
"measured_at": "2026-10-01T08:01:00"},
]}
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
"/api/production-runs/current": {"id": 5},
"/api/measurements/current-cycle": payload,
})
resp = logged_in_client.get("/measure/api/current-cycle/7")
assert resp.status_code == 200
assert resp.get_json() == payload
call = next(
c for c in mock_api_client.get.call_args_list
if c.args[0] == "/api/measurements/current-cycle"
)
assert call.kwargs["params"] == {"production_run_id": 5, "task_id": 7}
def test_no_run_means_empty_and_no_backend_call(
self, logged_in_client, mock_api_client,
):
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
"/api/production-runs/current": None,
})
resp = logged_in_client.get("/measure/api/current-cycle/7")
assert resp.get_json() == {"measurements": []}
assert not any(
c.args[0] == "/api/measurements/current-cycle"
for c in mock_api_client.get.call_args_list
)
def test_refuses_a_recipe_not_assigned_to_this_station(
self, logged_in_client, mock_api_client,
):
mock_api_client.get_station_recipes.return_value = [{"id": 99}]
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
})
assert logged_in_client.get("/measure/api/current-cycle/7").status_code == 403
def test_requires_login(self, client):
resp = client.get("/measure/api/current-cycle/7")
assert resp.status_code in (302, 401)
class TestStartSequence: class TestStartSequence:
"""GET /measure/start/<recipe_id> — the operator's way in.""" """GET /measure/start/<recipe_id> — the operator's way in."""
@@ -346,6 +407,21 @@ class TestTaskComplete:
assert resp.status_code == 200 assert resp.status_code == 200
assert b"productionClock(" in resp.data assert b"productionClock(" in resp.data
def test_measured_at_shown_in_local_time(self, logged_in_client, mock_api_client):
"""#42: the API sends UTC; the summary shows Europe/Rome."""
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
{"id": 1, "marker_number": 1, "description": "d", "nominal": 1.0,
"unit": "mm"}]}],
None,
{"items": [{"id": 1, "subtask_id": 1, "value": 1.0, "pass_fail": "pass",
"measured_at": "2026-10-06T08:00:00+00:00"}]},
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
assert "06/10/2026 10:00" in resp.get_data(as_text=True)
def test_measurements_filtered_by_lot_serial_and_operator( def test_measurements_filtered_by_lot_serial_and_operator(
self, logged_in_client, mock_api_client, self, logged_in_client, mock_api_client,
): ):