Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e44ac3129f | |||
| 4e52095423 | |||
| a829bf3eb0 | |||
| 7ac291b8ac | |||
| 486e1288bf | |||
| 0d004c43ff | |||
| 35f46926a9 | |||
| 1d93eded27 |
@@ -14,6 +14,7 @@ services:
|
||||
--authentication-policy=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--default-time-zone=+00:00
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
--authentication-policy=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--default-time-zone=+00:00
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"""API Key authentication dependency for FastAPI."""
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.services.auth_service import hash_api_key
|
||||
from src.backend.utils.tempo import utcnow
|
||||
|
||||
_RENEW_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
@@ -24,15 +32,25 @@ async def get_current_user(
|
||||
)
|
||||
|
||||
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(
|
||||
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
|
||||
request.state.user_id = user.id
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""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 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)
|
||||
async def logout(
|
||||
request: Request,
|
||||
all_sessions: bool = False,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Logout. The API key is shared across stations logged in as the same
|
||||
user, so by default this does not invalidate it - only this station's
|
||||
session (its Flask session, on the client side) ends. Pass
|
||||
?all_sessions=true to invalidate the key everywhere."""
|
||||
await logout_user(db, user, all_sessions=all_sessions)
|
||||
"""Logout. Deletes this session's API key only; ?all_sessions=true
|
||||
deletes every key of the user."""
|
||||
await logout_user(
|
||||
db, user, key=request.headers.get("X-API-Key"), all_sessions=all_sessions
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import csv
|
||||
import io
|
||||
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.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.user import User
|
||||
from src.backend.models.api.measurement import (
|
||||
CurrentCycleResponse,
|
||||
MeasurementAuthorisation,
|
||||
MeasurementBatchCreate,
|
||||
MeasurementCreate,
|
||||
@@ -97,6 +100,24 @@ async def get_task_progress(
|
||||
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)
|
||||
async def authorise_measurement(
|
||||
measurement_id: int,
|
||||
@@ -213,8 +234,8 @@ async def get_measurements(
|
||||
measured_by: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
production_run_id: int | None = Query(None, gt=0),
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -271,8 +292,8 @@ async def export_measurements_csv(
|
||||
measured_by: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
user: User = Depends(get_current_user),
|
||||
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.serial_number or ""),
|
||||
m.input_method,
|
||||
m.measured_at.isoformat(),
|
||||
iso_utc(m.measured_at),
|
||||
m.synced_to_csv,
|
||||
])
|
||||
|
||||
@@ -363,6 +384,6 @@ async def export_measurements_csv(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
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"
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""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.responses import Response
|
||||
@@ -18,11 +18,12 @@ async def download_spc_report(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
@@ -32,6 +33,7 @@ async def download_spc_report(
|
||||
db, recipe_id, subtask_id,
|
||||
version_id, date_from, date_to,
|
||||
operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
@@ -52,11 +54,12 @@ async def download_measurement_report(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int | None = Query(None),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
@@ -66,6 +69,7 @@ async def download_measurement_report(
|
||||
db, recipe_id, subtask_id,
|
||||
version_id, date_from, date_to,
|
||||
operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -5,8 +5,8 @@ NOT protected by API Key auth (standalone access).
|
||||
"""
|
||||
import hmac
|
||||
import random
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from src.backend.utils.tempo import iso_utc, utcnow
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
@@ -25,7 +25,7 @@ from src.backend.models.orm import (
|
||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
||||
)
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||
@@ -321,8 +321,6 @@ async def create_admin(body: CreateAdminBody):
|
||||
"""Create an admin user with all roles."""
|
||||
_check_password(body.password)
|
||||
|
||||
api_key = secrets.token_hex(32)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
user = User(
|
||||
@@ -332,9 +330,10 @@ async def create_admin(body: CreateAdminBody):
|
||||
display_name=body.admin_display_name,
|
||||
roles=["Maker", "MeasurementTec", "Metrologist"],
|
||||
is_admin=True,
|
||||
api_key=api_key,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
api_key = await issue_api_key(session, user)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
@@ -404,7 +403,6 @@ async def seed_demo_data(body: PasswordBody):
|
||||
email=u["email"],
|
||||
roles=u["roles"],
|
||||
is_admin=u["is_admin"],
|
||||
api_key=secrets.token_hex(32),
|
||||
)
|
||||
session.add(user)
|
||||
created_users.append(user)
|
||||
@@ -558,7 +556,7 @@ async def seed_demo_data(body: PasswordBody):
|
||||
tec_user = created_users[2] # tec1
|
||||
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)]
|
||||
base_date = datetime.now()
|
||||
base_date = utcnow()
|
||||
|
||||
all_subtasks = subtasks_1 + subtasks_2
|
||||
measurements_created = 0
|
||||
@@ -606,8 +604,8 @@ async def list_users(body: PasswordBody):
|
||||
"roles": u.roles or [],
|
||||
"is_admin": u.is_admin,
|
||||
"active": u.active,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
"last_login": u.last_login.isoformat() if u.last_login else None,
|
||||
"created_at": iso_utc(u.created_at) or None,
|
||||
"last_login": iso_utc(u.last_login) or None,
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
@@ -652,7 +650,6 @@ async def manage_user(body: ManageUserBody):
|
||||
email=body.email,
|
||||
roles=body.roles,
|
||||
is_admin=body.is_admin,
|
||||
api_key=secrets.token_hex(32),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
@@ -707,7 +704,7 @@ async def change_user_password(body: ChangeUserPasswordBody):
|
||||
detail=f"User ID {body.user_id} not found",
|
||||
)
|
||||
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}'"}
|
||||
|
||||
@@ -730,7 +727,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
|
||||
)
|
||||
user.active = 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"
|
||||
|
||||
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Statistics router - SPC endpoints for Metrologist dashboard."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcNaiveInput
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_metrologist
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
@@ -37,6 +39,7 @@ async def _query_measurements(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58: solo le misure di una sessione
|
||||
) -> list[Measurement]:
|
||||
"""Query measurements with common filters.
|
||||
|
||||
@@ -66,6 +69,9 @@ async def _query_measurements(
|
||||
if serial_number is not None:
|
||||
filters.append(Measurement.serial_number == serial_number)
|
||||
|
||||
if production_run_id is not None:
|
||||
filters.append(Measurement.production_run_id == production_run_id)
|
||||
|
||||
query = (
|
||||
select(Measurement)
|
||||
.where(and_(*filters) if filters else True)
|
||||
@@ -100,11 +106,12 @@ async def get_summary(
|
||||
recipe_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
subtask_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SummaryData:
|
||||
@@ -112,6 +119,7 @@ async def get_summary(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
pass_fail_values = [m.pass_fail for m in measurements]
|
||||
return compute_summary(pass_fail_values)
|
||||
@@ -122,11 +130,12 @@ async def get_capability(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CapabilityData:
|
||||
@@ -134,6 +143,7 @@ async def get_capability(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||
@@ -145,11 +155,12 @@ async def get_control_chart(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ControlChartData:
|
||||
@@ -157,6 +168,7 @@ async def get_control_chart(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
timestamps = [m.measured_at for m in measurements]
|
||||
@@ -172,11 +184,12 @@ async def get_histogram(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
n_bins: int = Query(20, ge=5, le=100),
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -185,6 +198,7 @@ async def get_histogram(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
return compute_histogram(values, n_bins)
|
||||
@@ -234,3 +248,49 @@ async def get_recipe_subtasks(
|
||||
})
|
||||
|
||||
return subtasks_list
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def get_recipe_runs(
|
||||
recipe_id: int = Query(...),
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""#58: production runs of a recipe (for the "Sessione" filter), newest first."""
|
||||
runs = (await db.execute(
|
||||
select(ProductionRun)
|
||||
.where(ProductionRun.recipe_id == recipe_id)
|
||||
.order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc())
|
||||
)).scalars().all()
|
||||
# #58: conta solo le misure dei run della ricetta, non tutta la tabella.
|
||||
run_ids = [r.id for r in runs]
|
||||
counts = {}
|
||||
if run_ids:
|
||||
counts = dict((await db.execute(
|
||||
select(Measurement.production_run_id, func.count(Measurement.id))
|
||||
.where(Measurement.production_run_id.in_(run_ids))
|
||||
.group_by(Measurement.production_run_id)
|
||||
)).all())
|
||||
operator_ids = {r.operator_id for r in runs}
|
||||
names = {}
|
||||
if operator_ids:
|
||||
names = dict((await db.execute(
|
||||
select(User.id, User.display_name).where(User.id.in_(operator_ids))
|
||||
)).all())
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"station_id": r.station_id,
|
||||
"station_name": r.station.name if r.station else None,
|
||||
"operator_id": r.operator_id,
|
||||
"operator_name": names.get(r.operator_id),
|
||||
"started_at": r.started_at.isoformat() if r.started_at else None,
|
||||
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
||||
"status": r.status,
|
||||
"lot_number": r.lot_number,
|
||||
"serial_number": r.serial_number,
|
||||
"cycle_count": r.cycle_count,
|
||||
"measurement_count": counts.get(r.id, 0),
|
||||
}
|
||||
for r in runs
|
||||
]
|
||||
|
||||
@@ -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.models.orm.user import User
|
||||
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"])
|
||||
|
||||
@@ -146,7 +146,7 @@ async def change_user_password(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user.password_hash = hash_password(data.password)
|
||||
user.api_key = None
|
||||
await revoke_all_keys(db, user.id)
|
||||
await db.flush()
|
||||
return {"message": f"Password changed for user {user.username}"}
|
||||
|
||||
@@ -168,7 +168,7 @@ async def deactivate_user(
|
||||
detail="Cannot deactivate yourself",
|
||||
)
|
||||
user.active = False
|
||||
user.api_key = None
|
||||
await revoke_all_keys(db, user.id)
|
||||
await db.flush()
|
||||
|
||||
|
||||
@@ -183,5 +183,5 @@ async def regenerate_user_key(
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
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}"}
|
||||
|
||||
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
|
||||
db_user: str = "tmflow"
|
||||
db_password: str = _DEFAULT_DB_PASSWORD
|
||||
|
||||
# Presentation time zone (DB is UTC, #42)
|
||||
display_timezone: str = "Europe/Rome"
|
||||
|
||||
# Server
|
||||
server_host: str = "0.0.0.0"
|
||||
server_port: int = 8000
|
||||
@@ -38,6 +41,9 @@ class Settings(BaseSettings):
|
||||
# configured limits to keep the cluster-wide effective limit close to what
|
||||
# was configured instead of multiplying it by N.
|
||||
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
|
||||
# have appended their own hop to X-Forwarded-For (Nginx, in the default
|
||||
# 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')
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for Measurement operations."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||
from typing import Optional
|
||||
|
||||
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
|
||||
# tolerance - and on one that is not and is still waiting for an answer.
|
||||
supervisor_id: Optional[int] = None
|
||||
authorised_at: Optional[datetime] = None
|
||||
measured_at: datetime
|
||||
authorised_at: Optional[UtcDateTime] = None
|
||||
measured_at: UtcDateTime
|
||||
synced_to_csv: bool
|
||||
|
||||
|
||||
@@ -91,6 +91,21 @@ class TaskProgressListResponse(BaseModel):
|
||||
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):
|
||||
"""Schema for measurement query filters."""
|
||||
recipe_id: Optional[int] = None
|
||||
@@ -99,8 +114,8 @@ class MeasurementQuery(BaseModel):
|
||||
measured_by: Optional[int] = None
|
||||
lot_number: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
date_from: UtcNaiveInput = None
|
||||
date_to: UtcNaiveInput = None
|
||||
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
||||
page: int = Field(1, ge=1)
|
||||
per_page: int = Field(50, ge=1, le=500)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""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 pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -21,7 +21,7 @@ class ProductionEventResponse(BaseModel):
|
||||
supervisor_id: Optional[int]
|
||||
task_id: Optional[int] = None
|
||||
note: Optional[str]
|
||||
created_at: datetime
|
||||
created_at: UtcDateTime
|
||||
|
||||
|
||||
class ProductionRunResponse(BaseModel):
|
||||
@@ -36,11 +36,11 @@ class ProductionRunResponse(BaseModel):
|
||||
serial_number: Optional[str]
|
||||
status: str
|
||||
measurement_interval_minutes: Optional[int]
|
||||
next_measurement_at: Optional[datetime]
|
||||
next_measurement_at: Optional[UtcDateTime]
|
||||
cycle_count: int
|
||||
started_at: datetime
|
||||
paused_at: Optional[datetime]
|
||||
closed_at: Optional[datetime]
|
||||
started_at: UtcDateTime
|
||||
paused_at: Optional[UtcDateTime]
|
||||
closed_at: Optional[UtcDateTime]
|
||||
closed_by: Optional[int]
|
||||
statistics_path: Optional[str] = None
|
||||
|
||||
@@ -49,7 +49,7 @@ class ProductionRunResponse(BaseModel):
|
||||
# overdue is a fact the operator must see, not deduce.
|
||||
seconds_to_next_measurement: Optional[int] = None
|
||||
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
|
||||
# needs them to bring the operator back to the measurement when the interval
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""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 pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -51,7 +51,7 @@ class RecipeVersionResponse(BaseModel):
|
||||
version_number: int
|
||||
is_current: bool
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
created_at: UtcDateTime
|
||||
change_notes: Optional[str] = None
|
||||
tasks: list["TaskResponse"] = []
|
||||
|
||||
@@ -72,7 +72,7 @@ class RecipeResponse(BaseModel):
|
||||
requires_serial: bool = False
|
||||
allow_manual_input: bool = False
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
created_at: UtcDateTime
|
||||
active: bool
|
||||
current_version: Optional[RecipeVersionResponse] = None
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -29,7 +29,7 @@ class StationResponse(BaseModel):
|
||||
notes: Optional[str]
|
||||
active: bool
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
created_at: UtcDateTime
|
||||
|
||||
|
||||
class StationRecipeAssignmentCreate(BaseModel):
|
||||
@@ -42,7 +42,7 @@ class StationRecipeAssignmentResponse(BaseModel):
|
||||
station_id: int
|
||||
recipe_id: int
|
||||
assigned_by: int
|
||||
assigned_at: datetime
|
||||
assigned_at: UtcDateTime
|
||||
|
||||
|
||||
class RecipeSummary(BaseModel):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for SPC statistics responses."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
|
||||
recipe_id: int
|
||||
version_id: Optional[int] = None
|
||||
subtask_id: Optional[int] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
date_from: UtcNaiveInput = None
|
||||
date_to: UtcNaiveInput = None
|
||||
operator_id: Optional[int] = None
|
||||
lot_number: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
|
||||
class ControlChartData(BaseModel):
|
||||
"""Data for X-bar/R control chart."""
|
||||
values: list[float]
|
||||
timestamps: list[datetime]
|
||||
timestamps: list[UtcDateTime]
|
||||
mean: float
|
||||
ucl: float # Upper Control Limit
|
||||
lcl: float # Lower Control Limit
|
||||
@@ -60,7 +60,7 @@ class CapabilityData(BaseModel):
|
||||
|
||||
class TrendData(BaseModel):
|
||||
"""Temporal trend data for capability indices."""
|
||||
dates: list[datetime]
|
||||
dates: list[UtcDateTime]
|
||||
cpk_values: list[Optional[float]]
|
||||
ppk_values: list[Optional[float]]
|
||||
n_per_period: list[int]
|
||||
|
||||
@@ -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)]
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for User operations."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -48,8 +48,8 @@ class UserResponse(BaseModel):
|
||||
language_pref: str
|
||||
theme_pref: str
|
||||
active: bool
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
created_at: UtcDateTime
|
||||
last_login: Optional[UtcDateTime] = None
|
||||
|
||||
|
||||
class UserPasswordChange(BaseModel):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for the vision execution response."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
@@ -28,4 +28,4 @@ class VisionReferenceImageResponse(BaseModel):
|
||||
note: str | None = None
|
||||
station_id: int | None = None
|
||||
device_code: str | None = None
|
||||
acquired_at: datetime
|
||||
acquired_at: UtcDateTime
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""SQLAlchemy models for TieMeasureFlow."""
|
||||
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.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
@@ -11,6 +12,7 @@ from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"ApiKey",
|
||||
"Recipe",
|
||||
"RecipeVersion",
|
||||
"RecipeTask",
|
||||
|
||||
@@ -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)
|
||||
@@ -32,7 +32,6 @@ class User(Base):
|
||||
|
||||
# Status
|
||||
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
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"""Authentication service - password hashing, API key management."""
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from src.backend.utils.tempo import utcnow
|
||||
|
||||
import bcrypt
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import delete, select, update
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
allowed to say yes.
|
||||
|
||||
Uses authenticate_user rather than a full login: logging in would rotate the
|
||||
supervisor's API key and knock out whatever session they have open elsewhere.
|
||||
Uses authenticate_user rather than a full login: logging in would issue a
|
||||
needless API key for the supervisor.
|
||||
"""
|
||||
supervisor = await authenticate_user(db, username, password)
|
||||
if supervisor is None:
|
||||
@@ -75,39 +79,67 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
||||
return supervisor
|
||||
|
||||
|
||||
async def login_user(db: AsyncSession, user: User) -> str:
|
||||
"""Generate (or reuse) the API key and update last_login for user.
|
||||
def hash_api_key(key: str) -> str:
|
||||
"""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
|
||||
pattern here) must keep both sessions working. Rotating the key would
|
||||
silently log the other station out.
|
||||
"""
|
||||
api_key = user.api_key or generate_api_key()
|
||||
|
||||
async def issue_api_key(db: AsyncSession, user: User) -> str:
|
||||
"""Create a new session key for the user and return it in clear (once)."""
|
||||
key = generate_api_key()
|
||||
now = utcnow()
|
||||
# Housekeeping: drop this user's expired keys so the table does not grow forever.
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(api_key=api_key, last_login=datetime.utcnow())
|
||||
delete(ApiKey).where(ApiKey.user_id == user.id, ApiKey.expires_at <= now)
|
||||
)
|
||||
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()
|
||||
return api_key
|
||||
|
||||
|
||||
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:
|
||||
"""Log out. By default a no-op on the shared API key.
|
||||
|
||||
Clearing the key on every logout would kick out any other station still
|
||||
using the same shift account. `all_sessions=True` is the explicit
|
||||
override for a supervisor who wants every session logged out.
|
||||
"""
|
||||
if not all_sessions:
|
||||
"""Log out. Deletes only the key of this session; all_sessions=True
|
||||
deletes every key of the user."""
|
||||
if all_sessions:
|
||||
await revoke_all_keys(db, user.id)
|
||||
return
|
||||
await db.execute(
|
||||
update(User).where(User.id == user.id).values(api_key=None)
|
||||
)
|
||||
await db.flush()
|
||||
if key:
|
||||
await db.execute(
|
||||
delete(ApiKey).where(
|
||||
ApiKey.user_id == user.id, ApiKey.key_hash == hash_api_key(key)
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def create_user(
|
||||
@@ -138,11 +170,7 @@ async def create_user(
|
||||
return user
|
||||
|
||||
|
||||
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
|
||||
"""Regenerate API key for a user."""
|
||||
new_key = generate_api_key()
|
||||
await db.execute(
|
||||
update(User).where(User.id == user_id).values(api_key=new_key)
|
||||
)
|
||||
await db.flush()
|
||||
return new_key
|
||||
async def regenerate_api_key(db: AsyncSession, user: User) -> str:
|
||||
"""Revoke every key of the user and issue a fresh one."""
|
||||
await revoke_all_keys(db, user.id)
|
||||
return await issue_api_key(db, user)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Measurement service - pass/fail calculation, data storage."""
|
||||
import math
|
||||
from datetime import datetime
|
||||
from src.backend.utils.tempo import utcnow
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -9,6 +10,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.task import (
|
||||
MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask,
|
||||
@@ -241,6 +243,73 @@ async def task_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(
|
||||
db: AsyncSession, run_id: int, version_id: int,
|
||||
) -> "ProductionRun":
|
||||
@@ -275,7 +344,7 @@ async def authorise_measurement(
|
||||
) -> Measurement:
|
||||
"""Let an out-of-tolerance value stand, on the supervisor's name."""
|
||||
measurement.supervisor_id = supervisor.id
|
||||
measurement.authorised_at = datetime.now()
|
||||
measurement.authorised_at = utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(measurement)
|
||||
return measurement
|
||||
|
||||
@@ -14,6 +14,7 @@ import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from src.backend.utils.tempo import iso_utc, to_local, utcnow
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, update
|
||||
@@ -169,9 +170,9 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
||||
measurement.input_method,
|
||||
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
||||
measurement.measured_by,
|
||||
measurement.measured_at.isoformat() if measurement.measured_at else "",
|
||||
iso_utc(measurement.measured_at),
|
||||
measurement.supervisor_id or "",
|
||||
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
|
||||
iso_utc(measurement.authorised_at),
|
||||
])
|
||||
|
||||
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.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"
|
||||
# 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.
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from src.backend.utils.tempo import utcnow
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
@@ -34,10 +35,10 @@ from src.backend.services import (
|
||||
def _now() -> datetime:
|
||||
"""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.
|
||||
"""
|
||||
return datetime.now()
|
||||
return utcnow()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import asyncio
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from src.backend.utils.tempo import to_local, utcnow
|
||||
from pathlib import Path
|
||||
|
||||
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(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
@@ -42,6 +48,7 @@ async def _query_measurements(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58
|
||||
) -> list[Measurement]:
|
||||
"""Query measurements with common filters (duplicated from statistics router)."""
|
||||
filters = []
|
||||
@@ -67,6 +74,9 @@ async def _query_measurements(
|
||||
if serial_number is not None:
|
||||
filters.append(Measurement.serial_number == serial_number)
|
||||
|
||||
if production_run_id is not None:
|
||||
filters.append(Measurement.production_run_id == production_run_id)
|
||||
|
||||
query = (
|
||||
select(Measurement)
|
||||
.where(and_(*filters) if filters else True)
|
||||
@@ -275,6 +285,7 @@ async def generate_spc_report(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58
|
||||
) -> bytes:
|
||||
"""Generate SPC PDF report and return bytes.
|
||||
|
||||
@@ -284,6 +295,7 @@ async def generate_spc_report(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
|
||||
# 2. Compute SPC data
|
||||
@@ -323,13 +335,15 @@ async def generate_spc_report(
|
||||
if version_id:
|
||||
filters_desc.append(f"Version: {version_id}")
|
||||
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:
|
||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
||||
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
||||
if lot_number:
|
||||
filters_desc.append(f"Lot: {lot_number}")
|
||||
if serial_number:
|
||||
filters_desc.append(f"Serial: {serial_number}")
|
||||
if production_run_id is not None:
|
||||
filters_desc.append(f"Run: {production_run_id}") # #58
|
||||
|
||||
# 6. Render HTML template
|
||||
template = _jinja_env.get_template("spc_report.html")
|
||||
@@ -342,7 +356,7 @@ async def generate_spc_report(
|
||||
control_chart_svg=control_chart_svg,
|
||||
histogram_svg=histogram_svg,
|
||||
filters_desc=filters_desc,
|
||||
generated_at=datetime.now(),
|
||||
generated_at=to_local(utcnow()),
|
||||
n_measurements=len(measurements),
|
||||
)
|
||||
|
||||
@@ -361,12 +375,14 @@ async def generate_measurement_report(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58
|
||||
) -> bytes:
|
||||
"""Generate measurement table PDF report and return bytes."""
|
||||
# 1. Query measurements
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
|
||||
# 2. Get recipe info
|
||||
@@ -395,7 +411,7 @@ async def generate_measurement_report(
|
||||
"value": f"{float(m.value):.4f}",
|
||||
"unit": st_info.get("unit", "mm"),
|
||||
"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}"),
|
||||
"lot_number": m.lot_number or "—",
|
||||
"serial_number": m.serial_number or "—",
|
||||
@@ -414,13 +430,15 @@ async def generate_measurement_report(
|
||||
if version_id:
|
||||
filters_desc.append(f"Version: {version_id}")
|
||||
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:
|
||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
||||
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
||||
if lot_number:
|
||||
filters_desc.append(f"Lot: {lot_number}")
|
||||
if serial_number:
|
||||
filters_desc.append(f"Serial: {serial_number}")
|
||||
if production_run_id is not None:
|
||||
filters_desc.append(f"Run: {production_run_id}") # #58
|
||||
|
||||
# 7. Render template
|
||||
template = _jinja_env.get_template("measurement_report.html")
|
||||
@@ -430,7 +448,7 @@ async def generate_measurement_report(
|
||||
rows=rows,
|
||||
summary=summary,
|
||||
filters_desc=filters_desc,
|
||||
generated_at=datetime.now(),
|
||||
generated_at=to_local(utcnow()),
|
||||
n_measurements=len(measurements),
|
||||
)
|
||||
|
||||
|
||||
@@ -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.setting import SystemSetting, RecipeVersionAudit
|
||||
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
|
||||
@@ -142,7 +142,6 @@ async def _create_user(
|
||||
active: bool = True,
|
||||
) -> User:
|
||||
"""Insert a user into the test DB and return it with an API key set."""
|
||||
api_key = generate_api_key()
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
@@ -150,13 +149,14 @@ async def _create_user(
|
||||
roles=roles or [],
|
||||
is_admin=is_admin,
|
||||
active=active,
|
||||
api_key=api_key,
|
||||
language_pref="en",
|
||||
theme_pref="light",
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
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
|
||||
|
||||
|
||||
@@ -278,4 +278,4 @@ async def create_test_recipe(
|
||||
|
||||
def auth_headers(user: User) -> dict[str, str]:
|
||||
"""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
@@ -78,75 +78,114 @@ class TestLogin:
|
||||
assert len(api_key) >= 32
|
||||
|
||||
|
||||
class TestLoginKeyReuse:
|
||||
"""login_user reuses an existing api_key instead of rotating it (#25)."""
|
||||
class TestLoginKeyPerSession:
|
||||
"""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
|
||||
):
|
||||
"""Two stations logging in as the same shift account both keep working."""
|
||||
await _create_user(db_session, username="twostations", password="TwoStations1")
|
||||
resp1 = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "twostations", "password": "TwoStations1"},
|
||||
)
|
||||
resp2 = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "twostations", "password": "TwoStations1"},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
assert resp2.status_code == 200
|
||||
assert resp1.json()["api_key"] == resp2.json()["api_key"]
|
||||
creds = {"username": "twostations", "password": "TwoStations1"}
|
||||
resp1 = await client.post("/api/auth/login", json=creds)
|
||||
resp2 = await client.post("/api/auth/login", json=creds)
|
||||
k1, k2 = resp1.json()["api_key"], resp2.json()["api_key"]
|
||||
assert k1 != k2
|
||||
for k in (k1, k2):
|
||||
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_login_generates_key_when_user_has_none(
|
||||
async def test_db_stores_hash_never_clear_key(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
"""A user created without an api_key (e.g. via create_user) still gets one."""
|
||||
from src.backend.services.auth_service import create_user
|
||||
from sqlalchemy import select
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.services.auth_service import hash_api_key
|
||||
|
||||
await create_user(
|
||||
db_session, username="brandnew", password="BrandNew1",
|
||||
display_name="Brand New",
|
||||
)
|
||||
await _create_user(db_session, username="hashed", password="HashedPass1")
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "brandnew", "password": "BrandNew1"},
|
||||
"/api/auth/login", json={"username": "hashed", "password": "HashedPass1"}
|
||||
)
|
||||
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.json()["api_key"]
|
||||
await db_session.refresh(row)
|
||||
assert row.expires_at >= before + timedelta(hours=23, minutes=59)
|
||||
|
||||
|
||||
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(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
user = await _create_user(
|
||||
db_session, username="shared_logout", password="SharedPass1"
|
||||
)
|
||||
headers = auth_headers(user)
|
||||
resp = await client.post("/api/auth/logout", headers=headers)
|
||||
async def test_logout_removes_only_own_key(self, client: AsyncClient, db_session):
|
||||
await _create_user(db_session, username="shared_logout", password="SharedPass1")
|
||||
creds = {"username": "shared_logout", "password": "SharedPass1"}
|
||||
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
resp = await client.post("/api/auth/logout", headers={"X-API-Key": k1})
|
||||
assert resp.status_code == 204
|
||||
|
||||
# The key is still valid: another station on the same account keeps working.
|
||||
resp = await client.get("/api/auth/me", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert (await client.get("/api/auth/me", headers={"X-API-Key": k1})).status_code == 401
|
||||
assert (await client.get("/api/auth/me", headers={"X-API-Key": k2})).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
|
||||
):
|
||||
user = await _create_user(
|
||||
db_session, username="shared_logout_all", password="SharedPass1"
|
||||
)
|
||||
headers = auth_headers(user)
|
||||
await _create_user(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"]
|
||||
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
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
|
||||
|
||||
resp = await client.get("/api/auth/me", headers=headers)
|
||||
assert resp.status_code == 401
|
||||
for k in (k1, k2):
|
||||
assert (await client.get("/api/auth/me", headers={"X-API-Key": k})).status_code == 401
|
||||
|
||||
|
||||
class TestAuthenticateUserTiming:
|
||||
@@ -185,3 +224,69 @@ class TestHealthAndAuth:
|
||||
resp = await client.get("/api/users")
|
||||
assert resp.status_code == 401
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -107,3 +107,27 @@ class TestBaseMigrationNoopsOnExistingSchema:
|
||||
assert "image_path" in {
|
||||
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()
|
||||
|
||||
@@ -211,3 +211,79 @@ class TestDateFilter:
|
||||
assert resp.status_code == 200
|
||||
# All measurements should be within this wide range
|
||||
assert resp.json()["total"] >= 2
|
||||
|
||||
|
||||
class TestRunFilter:
|
||||
"""#58: statistiche per sessione di produzione."""
|
||||
|
||||
async def _setup(self, db_session, user, metro):
|
||||
from datetime import datetime
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from sqlalchemy import select
|
||||
recipe = await create_test_recipe(db_session, user.id)
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().first()
|
||||
station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
runs = []
|
||||
for i, lot in enumerate(["L1", "L2"]):
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=user.id, status="closed", lot_number=lot,
|
||||
started_at=datetime(2026, 1, 1 + i),
|
||||
)
|
||||
db_session.add(run)
|
||||
runs.append(run)
|
||||
await db_session.commit()
|
||||
return recipe, runs
|
||||
|
||||
async def test_filter_by_run_and_unfiltered(
|
||||
self, client, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user)
|
||||
subtask_id, version_id = await _seed_measurements(
|
||||
client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2],
|
||||
)
|
||||
# Assegna le prime due misure al run 1, la terza al run 2.
|
||||
from sqlalchemy import select
|
||||
ms = (await db_session.execute(
|
||||
select(Measurement).order_by(Measurement.id)
|
||||
)).scalars().all()
|
||||
ms[0].production_run_id = runs[0].id
|
||||
ms[1].production_run_id = runs[0].id
|
||||
ms[2].production_run_id = runs[1].id
|
||||
await db_session.commit()
|
||||
|
||||
h = auth_headers(metrologist_user)
|
||||
r1 = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id, "production_run_id": runs[0].id})
|
||||
assert r1.json()["total"] == 2
|
||||
r2 = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id, "production_run_id": runs[1].id})
|
||||
assert r2.json()["total"] == 1
|
||||
r_all = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id})
|
||||
assert r_all.json()["total"] == 3
|
||||
|
||||
runs_resp = await client.get("/api/statistics/runs", headers=h,
|
||||
params={"recipe_id": recipe.id})
|
||||
assert runs_resp.status_code == 200
|
||||
data = runs_resp.json()
|
||||
assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima
|
||||
assert [r["measurement_count"] for r in data] == [1, 2]
|
||||
assert data[0]["lot_number"] == "L2"
|
||||
assert data[0]["station_name"] == "Stazione 58"
|
||||
assert data[0]["closed_at"] is None
|
||||
|
||||
async def test_runs_require_metrologist(
|
||||
self, client, measurement_tec_user, db_session,
|
||||
):
|
||||
resp = await client.get("/api/statistics/runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
params={"recipe_id": 1})
|
||||
assert resp.status_code == 403
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
old_key = maker_user.api_key
|
||||
old_key = maker_user.plain_key
|
||||
old_username = maker_user.username
|
||||
assert old_key is not None
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestChangeUserPassword:
|
||||
|
||||
# The old key no longer authenticates. Read it before the request
|
||||
# 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})
|
||||
assert resp2.status_code == 401
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -2,7 +2,8 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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)
|
||||
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")
|
||||
def tojson_attr_filter(value):
|
||||
"""JSON encode safe for HTML attributes (x-data, etc.).
|
||||
|
||||
@@ -595,6 +595,40 @@ def api_task_progress(task_id: int):
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -50,7 +50,7 @@ def _proxy_statistics(endpoint: str):
|
||||
for key in [
|
||||
"recipe_id", "version_id", "subtask_id",
|
||||
"date_from", "date_to", "operator_id",
|
||||
"lot_number", "serial_number", "n_bins",
|
||||
"lot_number", "serial_number", "n_bins", "production_run_id", # #58
|
||||
]:
|
||||
val = request.args.get(key)
|
||||
if val is not None and val != "":
|
||||
@@ -127,6 +127,27 @@ def api_subtasks():
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/runs")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_runs():
|
||||
"""Proxy: production runs of a recipe (#58, "Sessione" filter)."""
|
||||
params = {}
|
||||
recipe_id = request.args.get("recipe_id")
|
||||
if recipe_id:
|
||||
params["recipe_id"] = recipe_id
|
||||
|
||||
resp = api_client.get("/api/statistics/runs", params=params)
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
error_body = {"error": True, "detail": resp.get("detail", "")}
|
||||
if resp.get("session_expired"):
|
||||
error_body["session_expired"] = True
|
||||
return jsonify(error_body), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PROXY REPORT PDF → FastAPI
|
||||
# ============================================================================
|
||||
@@ -137,7 +158,7 @@ def _proxy_report(endpoint: str):
|
||||
for key in [
|
||||
"recipe_id", "version_id", "subtask_id",
|
||||
"date_from", "date_to", "operator_id",
|
||||
"lot_number", "serial_number",
|
||||
"lot_number", "serial_number", "production_run_id", # #58
|
||||
]:
|
||||
val = request.args.get(key)
|
||||
if val is not None and val != "":
|
||||
|
||||
@@ -56,6 +56,8 @@ class Config:
|
||||
# Babel i18n
|
||||
BABEL_DEFAULT_LOCALE = "it"
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ function csvExport() {
|
||||
*/
|
||||
generateFilename(prefix) {
|
||||
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
|
||||
|
||||
return `${prefix}_${dateStr}_${timeStr}.csv`;
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
|
||||
<tr>
|
||||
<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 style="color: var(--text-secondary);" class="text-sm">
|
||||
{{ m.task_info.title if m.task_info else '-' }}
|
||||
|
||||
@@ -1100,6 +1100,7 @@ function taskExecute() {
|
||||
this.inputStartedAt = Date.now();
|
||||
// Rejoin whatever production is already open at this station.
|
||||
await this.loadProductionRun();
|
||||
await this.loadCurrentCycle();
|
||||
await this.loadPendingAuthorisation();
|
||||
// A tablet left to sleep drifts (#37.7): catch the countdown back up to
|
||||
// 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?
|
||||
|
||||
Reloading the page was the simplest way past the old gate. It is not one
|
||||
|
||||
@@ -35,6 +35,19 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Sessione di produzione (#58) -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Sessione") }}</label>
|
||||
<select x-model="selectedRunId"
|
||||
:disabled="runs.length === 0"
|
||||
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
|
||||
<option value="">{{ _("Tutte le sessioni") }}</option>
|
||||
<template x-for="r in runs" :key="r.id">
|
||||
<option :value="r.id" x-text="runLabel(r)"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Subtask -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
||||
@@ -258,6 +271,8 @@ function spcDashboard() {
|
||||
// Filter state
|
||||
selectedRecipeId: '',
|
||||
selectedSubtaskId: '',
|
||||
selectedRunId: '', // #58
|
||||
runs: [], // #58 sessioni della ricetta
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
subtasks: [],
|
||||
@@ -281,6 +296,8 @@ function spcDashboard() {
|
||||
|
||||
async onRecipeChange() {
|
||||
this.selectedSubtaskId = '';
|
||||
this.selectedRunId = ''; // #58
|
||||
this.runs = [];
|
||||
this.subtasks = [];
|
||||
this.hasData = false;
|
||||
this.summary = null;
|
||||
@@ -290,6 +307,15 @@ function spcDashboard() {
|
||||
|
||||
if (!this.selectedRecipeId) return;
|
||||
|
||||
// #58: sessioni della ricetta per il filtro «Sessione»
|
||||
try {
|
||||
const rresp = await fetch('/statistics/api/runs?recipe_id=' + this.selectedRecipeId);
|
||||
const rdata = await rresp.json();
|
||||
if (Array.isArray(rdata)) this.runs = rdata;
|
||||
} catch (e) {
|
||||
console.error('Error loading runs:', e);
|
||||
}
|
||||
|
||||
// Load subtasks for dropdown
|
||||
try {
|
||||
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
|
||||
@@ -302,6 +328,12 @@ function spcDashboard() {
|
||||
}
|
||||
},
|
||||
|
||||
// #58: etichetta sessione = data, stazione, operatore, lotto
|
||||
runLabel(r) {
|
||||
const d = r.started_at ? new Date(r.started_at + (r.started_at.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
|
||||
return [d, r.station_name, r.operator_name, r.lot_number].filter(Boolean).join(' · ');
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
if (!this.selectedRecipeId) return;
|
||||
|
||||
@@ -312,8 +344,9 @@ function spcDashboard() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('recipe_id', this.selectedRecipeId);
|
||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
|
||||
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
|
||||
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||
|
||||
try {
|
||||
// Parallel fetch of all endpoints
|
||||
@@ -417,8 +450,9 @@ function spcDashboard() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('recipe_id', this.selectedRecipeId);
|
||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
|
||||
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
|
||||
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||
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 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)
|
||||
|
||||
|
||||
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:
|
||||
"""GET /measure/start/<recipe_id> — the operator's way in."""
|
||||
|
||||
@@ -346,6 +407,21 @@ class TestTaskComplete:
|
||||
assert resp.status_code == 200
|
||||
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(
|
||||
self, logged_in_client, mock_api_client,
|
||||
):
|
||||
|
||||
@@ -112,3 +112,21 @@ class TestSPCProxy:
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "cp" in data
|
||||
|
||||
|
||||
class TestRunsProxy:
|
||||
"""#58: filtro per sessione di produzione."""
|
||||
|
||||
def test_runs_proxy(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = [{"id": 5, "measurement_count": 3}]
|
||||
resp = logged_in_client.get("/statistics/api/runs?recipe_id=7")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()[0]["id"] == 5
|
||||
args, kwargs = mock_api_client.get.call_args
|
||||
assert args[0] == "/api/statistics/runs"
|
||||
assert kwargs["params"] == {"recipe_id": "7"}
|
||||
|
||||
def test_summary_forwards_production_run_id(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = {"total": 1}
|
||||
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
|
||||
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
|
||||
|
||||
Reference in New Issue
Block a user