Compare commits

...

6 Commits

Author SHA1 Message Date
Adriano 3856ee335a operatore: acquisizione immagine, proxy execute e overlay per camera_measure/dxf_compare (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:55:42 +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
45 changed files with 1040 additions and 184 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)
+8 -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
@@ -232,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),
@@ -290,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),
@@ -372,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,
]) ])
@@ -382,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}"}
+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')
+6 -6
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
@@ -97,7 +97,7 @@ class CycleMeasurementResponse(BaseModel):
subtask_id: int subtask_id: int
value: float value: float
pass_fail: str pass_fail: str
measured_at: datetime measured_at: UtcDateTime
class CurrentCycleResponse(BaseModel): class CurrentCycleResponse(BaseModel):
@@ -114,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):
+2 -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
@@ -28,4 +28,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)
-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
+2 -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
@@ -343,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()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+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),
) )
+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
+24
View File
@@ -107,3 +107,27 @@ 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()
+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
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.).
+67 -1
View File
@@ -1,8 +1,10 @@
"""MeasurementTec blueprint - recipe selection and measurement execution.""" """MeasurementTec blueprint - recipe selection and measurement execution."""
import urllib.parse import urllib.parse
import requests as http_requests
from flask import ( from flask import (
Blueprint, abort, flash, jsonify, redirect, render_template, Blueprint, Response, abort, flash, jsonify, redirect, render_template,
request, session, url_for, request, session, url_for,
) )
from flask_babel import gettext as _ from flask_babel import gettext as _
@@ -820,3 +822,67 @@ def api_close_production(run_id: int):
def api_get_file(file_path: str): def api_get_file(file_path: str):
"""Proxy: Serve file from API server (browser can't send X-API-Key).""" """Proxy: Serve file from API server (browser can't send X-API-Key)."""
return proxy_file(file_path) return proxy_file(file_path)
# ---------------------------------------------------------------------------
# Route: vision execute (#46) - the browser sends an image, we relay it to
# POST /api/vision/execute (multipart) because it cannot send X-API-Key itself.
# ---------------------------------------------------------------------------
@measure_bp.route("/api/vision/execute", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_vision_execute():
"""Proxy: run the task's VisionSuite recipe on the uploaded image."""
image = request.files.get("image")
task_id = request.form.get("task_id")
if image is None or not task_id:
return jsonify({
"error": True,
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
}), 400
# Same context fields as the manual flow; empty ones are left out so the
# backend sees them as absent rather than as "".
form = {"task_id": task_id}
for key in ("task_type", "lot_number", "serial_number", "production_run_id"):
val = request.form.get(key)
if val:
form[key] = val
form.setdefault("lot_number", session.get("lot_number", ""))
form.setdefault("serial_number", session.get("serial_number", ""))
resp = api_client.post(
"/api/vision/execute",
data=form,
files={"image": (
image.filename or "capture.jpg",
image.read(),
image.mimetype or "application/octet-stream",
)},
)
if resp.get("error"):
status_code = resp.get("status_code", 500)
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
if resp.get("session_expired"):
body["session_expired"] = True
return jsonify(body), status_code if status_code >= 400 else 500
return jsonify(resp), 200
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_vision_result_image(result_id: int):
"""Proxy: the image saved with a vision result (needs X-API-Key)."""
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/vision/results/{result_id}/image",
headers={"X-API-Key": session.get("api_key", "")},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
+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`;
@@ -0,0 +1,125 @@
/**
* Vision execute - Alpine component for camera_measure / dxf_compare tasks (#46).
*
* The operator takes (or picks) an image, `acquire(blob)` sends it to the Flask
* proxy, and the result - quote table, outcome, failures, overlay - is shown on
* the image. `acquire(blob)` is the single entry point on purpose: the agent
* camera (#3) will call it with its own blob and reuse everything below.
*
* Config comes from window.__visionCfg (set by the template):
* executeUrl, imageUrlTpl (with a 0 placeholder for the result id), taskId,
* taskType, subtasks, labels.
* Run context (lot, serial, production run) is read lazily through `ctx()`,
* which the template binds to the parent task component.
*/
function visionExecute() {
// Overlay colour per role (VisionSuite roles: reference, roi, found).
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
return {
busy: false,
error: '',
result: null, // last execute response
imageSrc: '', // object URL of the acquired image (shown under the overlay)
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
// Entry point shared with the future agent camera.
async acquire(blob) {
if (!blob || this.busy) return;
const cfg = window.__visionCfg || {};
this.busy = true;
this.error = '';
this.result = null;
// Show the picked image at once: the overlay is drawn on top when the result arrives.
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageSrc = URL.createObjectURL(blob);
try {
const c = this.ctx() || {};
const fd = new FormData();
fd.append('task_id', cfg.taskId);
fd.append('task_type', cfg.taskType || '');
fd.append('image', blob, blob.name || 'capture.jpg');
fd.append('lot_number', c.lot || '');
fd.append('serial_number', c.serial || '');
if (c.runId) fd.append('production_run_id', c.runId);
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
const resp = await fetch(cfg.executeUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok || body.error) {
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return;
}
this.result = body;
// Draw once Alpine has rendered the canvas.
this.$nextTick(() => this.drawOverlay());
} catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
} finally {
this.busy = false;
}
},
// Input change: take the file and allow picking the same one again.
onPick(ev) {
const f = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (f) this.acquire(f);
},
// Image shown: the saved one from the server when available, else the local one.
get shownImage() {
const cfg = window.__visionCfg || {};
if (this.result && this.result.vision_result_id && cfg.imageUrlTpl) {
return cfg.imageUrlTpl.replace('/0/', '/' + this.result.vision_result_id + '/');
}
return this.imageSrc;
},
// Quote rows: server measurements joined with the subtask names.
get rows() {
const cfg = window.__visionCfg || {};
const subs = cfg.subtasks || [];
return ((this.result && this.result.measurements) || []).map((m) => {
const st = subs.find((s) => s.id === m.subtask_id) || {};
return {
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
value: m.value,
unit: st.unit || 'mm',
status: m.pass_fail,
};
});
},
statusClass(s) {
// Same palette as the measurement markers of the manual flow.
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
if (s === 'fail') return 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40';
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
},
// Canvas is sized to the natural image, so overlay coordinates (image px)
// are used as they are and CSS scales the whole thing to the displayed size.
drawOverlay() {
const img = this.$refs.img, cv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineWidth = Math.max(2, cv.width / 400);
g.lineJoin = 'round';
for (const item of ((this.result && this.result.overlay) || [])) {
const rows = item.rows || [], cols = item.cols || [];
if (!rows.length || rows.length !== cols.length) continue;
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
g.beginPath();
g.moveTo(cols[0], rows[0]);
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
g.stroke();
}
},
};
}
@@ -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 '-' }}
@@ -191,7 +191,8 @@
{# ================================================================ {# ================================================================
MAIN — 3-column layout MAIN — 3-column layout
================================================================ #} ================================================================ #}
<div class="flex-1 flex overflow-hidden"> {# #46: camera_measure / dxf_compare get the acquisition panel below instead of the manual layout #}
<div class="flex-1 flex overflow-hidden" x-show="!isVisionTask">
{# ────────────────────────────────────────────── {# ──────────────────────────────────────────────
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks) LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
@@ -451,6 +452,63 @@
</div> </div>
{# ================================================================
VISION PANEL (#46) - acquire an image, run the recipe server-side, show the
quotes and the overlay. acquire(blob) is the entry point the agent camera
(#3) will reuse.
================================================================ #}
<div x-show="isVisionTask" x-cloak
x-data="visionExecute()"
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
<div class="flex-1 min-w-0 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
<input type="file" accept="image/*" capture="environment" class="hidden"
@change="onPick($event)" :disabled="busy">
</label>
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
</div>
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div>
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
</div>
</div>
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
<div class="text-sm font-bold text-[var(--text-primary)]">
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
</div>
<table class="w-full text-sm" x-show="rows.length">
<template x-for="r in rows" :key="r.label">
<tr>
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
</tr>
</template>
</table>
<div x-show="result && result.failures && result.failures.length"
class="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-xs">
<div class="font-bold mb-1">{{ _('Strumenti falliti') }}</div>
<template x-for="f in ((result && result.failures) || [])" :key="f.tool_id">
<div><span class="font-mono" x-text="f.tool_name || f.tool_id"></span>: <span x-text="f.error"></span></div>
</template>
</div>
</div>
</div>
{# ================================================================ {# ================================================================
MEASUREMENT TIMER BANNER MEASUREMENT TIMER BANNER
================================================================ #} ================================================================ #}
@@ -912,6 +970,18 @@
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script> <script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script> <script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script> <script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
{# #46: acquisition + overlay for camera_measure / dxf_compare #}
<script>
window.__visionCfg = {
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
taskId: {{ task.id|tojson }},
taskType: {{ (task.task_type or '')|tojson }},
subtasks: window.__taskSubtasks,
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
};
</script>
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
{# The clock's rules - how a delay reads, when the buzzer goes, where the {# The clock's rules - how a delay reads, when the buzzer goes, where the
measurement lives - are shared with the other screens rather than restated. #} measurement lives - are shared with the other screens rather than restated. #}
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script> <script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
@@ -1776,6 +1846,12 @@ function taskExecute() {
return declared === 'measure' || declared === 'camera_measure'; return declared === 'measure' || declared === 'camera_measure';
}, },
/* #46: image-based tasks run a VisionSuite recipe on the server. */
get isVisionTask() {
const t = this.task.task_type;
return t === 'camera_measure' || t === 'dxf_compare';
},
/* A measurement task with no quotes yet: the recipe is unfinished. Said out /* A measurement task with no quotes yet: the recipe is unfinished. Said out
loud rather than silently rendering an empty screen. */ loud rather than silently rendering an empty screen. */
get isMeasureTaskWithoutQuotes() { get isMeasureTaskWithoutQuotes() {
@@ -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) == "-"
@@ -407,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,
): ):
@@ -0,0 +1,86 @@
"""gitea:Adriano/TieMeasureFlow#46 - operator side of the vision tasks.
The backend of contract B is not merged yet: api_client is mocked.
"""
import io
from unittest.mock import MagicMock, patch
from tests.conftest import api_get_router
TASK = {
"id": 12, "recipe_id": 3, "version_id": 5, "task_type": "camera_measure",
"title": "Foto", "directive": None, "description": None, "file_path": None,
"file_type": None, "annotations_json": None, "subtasks": [],
}
RECIPE = {
"id": 3, "code": "REC-1", "name": "Ricetta",
"measurement_interval_minutes": 30, "allow_manual_input": True,
"current_version": {"id": 5, "version_number": 1},
}
def test_execute_page_has_acquire_input(logged_in_client, mock_api_client, monkeypatch):
"""camera_measure screen offers the file/camera input and the acquire entry point."""
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/12": TASK,
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
"/api/recipes/3": RECIPE,
})
html = logged_in_client.get("/measure/execute/12").get_data(as_text=True)
assert 'capture="environment"' in html
assert 'accept="image/*"' in html
assert "visionExecute()" in html
assert "/measure/api/vision/execute" in html
def test_vision_execute_proxy_forwards_multipart(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {
"vision_result_id": 7, "outcome": "ok", "overlay": [], "failures": [],
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
}
resp = logged_in_client.post(
"/measure/api/vision/execute",
data={
"task_id": "12", "lot_number": "L1", "serial_number": "S1",
"production_run_id": "4",
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
},
content_type="multipart/form-data",
)
assert resp.status_code == 200
assert resp.get_json()["vision_result_id"] == 7
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/execute"
assert kwargs["data"]["task_id"] == "12"
assert kwargs["data"]["lot_number"] == "L1"
assert kwargs["data"]["production_run_id"] == "4"
name, content, mime = kwargs["files"]["image"]
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
def test_vision_execute_proxy_requires_image(logged_in_client, mock_api_client):
resp = logged_in_client.post("/measure/api/vision/execute", data={"task_id": "12"})
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_vision_execute_proxy_relays_backend_error(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "bad"}
resp = logged_in_client.post(
"/measure/api/vision/execute",
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
content_type="multipart/form-data",
)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "bad"
def test_vision_result_image_proxy(logged_in_client):
fake = MagicMock(status_code=200, content=b"IMG", headers={"content-type": "image/jpeg"})
with patch("blueprints.measure.http_requests.get", return_value=fake) as g:
resp = logged_in_client.get("/measure/api/vision/results/7/image")
assert resp.status_code == 200 and resp.data == b"IMG"
assert g.call_args[0][0].endswith("/api/vision/results/7/image")
assert g.call_args[1]["headers"]["X-API-Key"] == "test-api-key-12345"