Compare commits

...

6 Commits

41 changed files with 832 additions and 179 deletions
+1
View File
@@ -14,6 +14,7 @@ services:
--authentication-policy=mysql_native_password --authentication-policy=mysql_native_password
--character-set-server=utf8mb4 --character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci --collation-server=utf8mb4_unicode_ci
--default-time-zone=+00:00
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s interval: 10s
+1
View File
@@ -14,6 +14,7 @@ services:
--authentication-policy=mysql_native_password --authentication-policy=mysql_native_password
--character-set-server=utf8mb4 --character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci --collation-server=utf8mb4_unicode_ci
--default-time-zone=+00:00
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s interval: 10s
+2 -1
View File
@@ -9,7 +9,8 @@ from src.backend.database import get_db
from src.backend.config import settings from src.backend.config import settings
from src.backend.models.orm.api_key import ApiKey from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.services.auth_service import hash_api_key, utcnow from src.backend.services.auth_service import hash_api_key
from src.backend.utils.tempo import utcnow
_RENEW_INTERVAL = timedelta(minutes=5) _RENEW_INTERVAL = timedelta(minutes=5)
+8 -6
View File
@@ -2,6 +2,8 @@
import csv import csv
import io import io
from datetime import datetime from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from src.backend.utils.tempo import as_utc_naive, iso_utc, to_local, utcnow
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -232,8 +234,8 @@ async def get_measurements(
measured_by: int | None = Query(None), measured_by: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
production_run_id: int | None = Query(None, gt=0), production_run_id: int | None = Query(None, gt=0),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@@ -290,8 +292,8 @@ async def export_measurements_csv(
measured_by: int | None = Query(None), measured_by: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -372,7 +374,7 @@ async def export_measurements_csv(
neutralise_csv_cell(m.lot_number or ""), neutralise_csv_cell(m.lot_number or ""),
neutralise_csv_cell(m.serial_number or ""), neutralise_csv_cell(m.serial_number or ""),
m.input_method, m.input_method,
m.measured_at.isoformat(), iso_utc(m.measured_at),
m.synced_to_csv, m.synced_to_csv,
]) ])
@@ -382,6 +384,6 @@ async def export_measurements_csv(
iter([output.getvalue()]), iter([output.getvalue()]),
media_type="text/csv", media_type="text/csv",
headers={ headers={
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" "Content-Disposition": f"attachment; filename=measurements_{to_local(utcnow()).strftime('%Y%m%d_%H%M%S')}.csv"
}, },
) )
+5 -5
View File
@@ -1,5 +1,5 @@
"""Reports router — PDF report generation endpoints for Metrologist.""" """Reports router — PDF report generation endpoints for Metrologist."""
from datetime import datetime from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response from fastapi.responses import Response
@@ -18,8 +18,8 @@ async def download_spc_report(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
@@ -52,8 +52,8 @@ async def download_measurement_report(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int | None = Query(None), subtask_id: int | None = Query(None),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
+4 -3
View File
@@ -6,6 +6,7 @@ NOT protected by API Key auth (standalone access).
import hmac import hmac
import random import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
from src.backend.utils.tempo import iso_utc, utcnow
from pathlib import Path from pathlib import Path
from alembic import command from alembic import command
@@ -555,7 +556,7 @@ async def seed_demo_data(body: PasswordBody):
tec_user = created_users[2] # tec1 tec_user = created_users[2] # tec1
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"] lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)] serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
base_date = datetime.now() base_date = utcnow()
all_subtasks = subtasks_1 + subtasks_2 all_subtasks = subtasks_1 + subtasks_2
measurements_created = 0 measurements_created = 0
@@ -603,8 +604,8 @@ async def list_users(body: PasswordBody):
"roles": u.roles or [], "roles": u.roles or [],
"is_admin": u.is_admin, "is_admin": u.is_admin,
"active": u.active, "active": u.active,
"created_at": u.created_at.isoformat() if u.created_at else None, "created_at": iso_utc(u.created_at) or None,
"last_login": u.last_login.isoformat() if u.last_login else None, "last_login": iso_utc(u.last_login) or None,
} }
for u in users for u in users
], ],
+9 -8
View File
@@ -1,5 +1,6 @@
"""Statistics router - SPC endpoints for Metrologist dashboard.""" """Statistics router - SPC endpoints for Metrologist dashboard."""
from datetime import datetime from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, select from sqlalchemy import and_, select
@@ -100,8 +101,8 @@ async def get_summary(
recipe_id: int = Query(...), recipe_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
subtask_id: int | None = Query(None), subtask_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
@@ -122,8 +123,8 @@ async def get_capability(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
@@ -145,8 +146,8 @@ async def get_control_chart(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
@@ -172,8 +173,8 @@ async def get_histogram(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
+3
View File
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
db_user: str = "tmflow" db_user: str = "tmflow"
db_password: str = _DEFAULT_DB_PASSWORD db_password: str = _DEFAULT_DB_PASSWORD
# Presentation time zone (DB is UTC, #42)
display_timezone: str = "Europe/Rome"
# Server # Server
server_host: str = "0.0.0.0" server_host: str = "0.0.0.0"
server_port: int = 8000 server_port: int = 8000
+6 -6
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Measurement operations.""" """Pydantic schemas for Measurement operations."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -54,8 +54,8 @@ class MeasurementResponse(BaseModel):
# Who let this value stand, when it was out of tolerance. Null on a value in # Who let this value stand, when it was out of tolerance. Null on a value in
# tolerance - and on one that is not and is still waiting for an answer. # tolerance - and on one that is not and is still waiting for an answer.
supervisor_id: Optional[int] = None supervisor_id: Optional[int] = None
authorised_at: Optional[datetime] = None authorised_at: Optional[UtcDateTime] = None
measured_at: datetime measured_at: UtcDateTime
synced_to_csv: bool synced_to_csv: bool
@@ -97,7 +97,7 @@ class CycleMeasurementResponse(BaseModel):
subtask_id: int subtask_id: int
value: float value: float
pass_fail: str pass_fail: str
measured_at: datetime measured_at: UtcDateTime
class CurrentCycleResponse(BaseModel): class CurrentCycleResponse(BaseModel):
@@ -114,8 +114,8 @@ class MeasurementQuery(BaseModel):
measured_by: Optional[int] = None measured_by: Optional[int] = None
lot_number: Optional[str] = None lot_number: Optional[str] = None
serial_number: Optional[str] = None serial_number: Optional[str] = None
date_from: Optional[datetime] = None date_from: UtcNaiveInput = None
date_to: Optional[datetime] = None date_to: UtcNaiveInput = None
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$") pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
page: int = Field(1, ge=1) page: int = Field(1, ge=1)
per_page: int = Field(50, ge=1, le=500) per_page: int = Field(50, ge=1, le=500)
+7 -7
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for production runs and their events.""" """Pydantic schemas for production runs and their events."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from typing import Literal, Optional from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -21,7 +21,7 @@ class ProductionEventResponse(BaseModel):
supervisor_id: Optional[int] supervisor_id: Optional[int]
task_id: Optional[int] = None task_id: Optional[int] = None
note: Optional[str] note: Optional[str]
created_at: datetime created_at: UtcDateTime
class ProductionRunResponse(BaseModel): class ProductionRunResponse(BaseModel):
@@ -36,11 +36,11 @@ class ProductionRunResponse(BaseModel):
serial_number: Optional[str] serial_number: Optional[str]
status: str status: str
measurement_interval_minutes: Optional[int] measurement_interval_minutes: Optional[int]
next_measurement_at: Optional[datetime] next_measurement_at: Optional[UtcDateTime]
cycle_count: int cycle_count: int
started_at: datetime started_at: UtcDateTime
paused_at: Optional[datetime] paused_at: Optional[UtcDateTime]
closed_at: Optional[datetime] closed_at: Optional[UtcDateTime]
closed_by: Optional[int] closed_by: Optional[int]
statistics_path: Optional[str] = None statistics_path: Optional[str] = None
@@ -49,7 +49,7 @@ class ProductionRunResponse(BaseModel):
# overdue is a fact the operator must see, not deduce. # overdue is a fact the operator must see, not deduce.
seconds_to_next_measurement: Optional[int] = None seconds_to_next_measurement: Optional[int] = None
overdue: bool = False overdue: bool = False
server_time: Optional[datetime] = None server_time: Optional[UtcDateTime] = None
# The measurement tasks of this run's version, in execution order. The client # The measurement tasks of this run's version, in execution order. The client
# needs them to bring the operator back to the measurement when the interval # needs them to bring the operator back to the measurement when the interval
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Recipe and RecipeVersion operations.""" """Pydantic schemas for Recipe and RecipeVersion operations."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -51,7 +51,7 @@ class RecipeVersionResponse(BaseModel):
version_number: int version_number: int
is_current: bool is_current: bool
created_by: int created_by: int
created_at: datetime created_at: UtcDateTime
change_notes: Optional[str] = None change_notes: Optional[str] = None
tasks: list["TaskResponse"] = [] tasks: list["TaskResponse"] = []
@@ -72,7 +72,7 @@ class RecipeResponse(BaseModel):
requires_serial: bool = False requires_serial: bool = False
allow_manual_input: bool = False allow_manual_input: bool = False
created_by: int created_by: int
created_at: datetime created_at: UtcDateTime
active: bool active: bool
current_version: Optional[RecipeVersionResponse] = None current_version: Optional[RecipeVersionResponse] = None
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Station and StationRecipeAssignment.""" """Pydantic schemas for Station and StationRecipeAssignment."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -29,7 +29,7 @@ class StationResponse(BaseModel):
notes: Optional[str] notes: Optional[str]
active: bool active: bool
created_by: int created_by: int
created_at: datetime created_at: UtcDateTime
class StationRecipeAssignmentCreate(BaseModel): class StationRecipeAssignmentCreate(BaseModel):
@@ -42,7 +42,7 @@ class StationRecipeAssignmentResponse(BaseModel):
station_id: int station_id: int
recipe_id: int recipe_id: int
assigned_by: int assigned_by: int
assigned_at: datetime assigned_at: UtcDateTime
class RecipeSummary(BaseModel): class RecipeSummary(BaseModel):
+5 -5
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for SPC statistics responses.""" """Pydantic schemas for SPC statistics responses."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
recipe_id: int recipe_id: int
version_id: Optional[int] = None version_id: Optional[int] = None
subtask_id: Optional[int] = None subtask_id: Optional[int] = None
date_from: Optional[datetime] = None date_from: UtcNaiveInput = None
date_to: Optional[datetime] = None date_to: UtcNaiveInput = None
operator_id: Optional[int] = None operator_id: Optional[int] = None
lot_number: Optional[str] = None lot_number: Optional[str] = None
serial_number: Optional[str] = None serial_number: Optional[str] = None
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
class ControlChartData(BaseModel): class ControlChartData(BaseModel):
"""Data for X-bar/R control chart.""" """Data for X-bar/R control chart."""
values: list[float] values: list[float]
timestamps: list[datetime] timestamps: list[UtcDateTime]
mean: float mean: float
ucl: float # Upper Control Limit ucl: float # Upper Control Limit
lcl: float # Lower Control Limit lcl: float # Lower Control Limit
@@ -60,7 +60,7 @@ class CapabilityData(BaseModel):
class TrendData(BaseModel): class TrendData(BaseModel):
"""Temporal trend data for capability indices.""" """Temporal trend data for capability indices."""
dates: list[datetime] dates: list[UtcDateTime]
cpk_values: list[Optional[float]] cpk_values: list[Optional[float]]
ppk_values: list[Optional[float]] ppk_values: list[Optional[float]]
n_per_period: list[int] n_per_period: list[int]
+14
View File
@@ -0,0 +1,14 @@
"""Shared Pydantic types for datetimes (#42): UTC in the DB, explicit +00:00 on the wire."""
from datetime import datetime
from typing import Annotated, Optional
from pydantic import AfterValidator, PlainSerializer
from src.backend.utils.tempo import as_utc_naive, to_utc_aware
UtcDateTime = Annotated[
datetime, PlainSerializer(lambda v: to_utc_aware(v).isoformat(), return_type=str)
]
# Input: aware values are converted to UTC naive, naive is taken as UTC.
UtcNaiveInput = Annotated[Optional[datetime], AfterValidator(as_utc_naive)]
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for User operations.""" """Pydantic schemas for User operations."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -48,8 +48,8 @@ class UserResponse(BaseModel):
language_pref: str language_pref: str
theme_pref: str theme_pref: str
active: bool active: bool
created_at: datetime created_at: UtcDateTime
last_login: Optional[datetime] = None last_login: Optional[UtcDateTime] = None
class UserPasswordChange(BaseModel): class UserPasswordChange(BaseModel):
+2 -2
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for the vision execution response.""" """Pydantic schemas for the vision execution response."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -28,4 +28,4 @@ class VisionReferenceImageResponse(BaseModel):
note: str | None = None note: str | None = None
station_id: int | None = None station_id: int | None = None
device_code: str | None = None device_code: str | None = None
acquired_at: datetime acquired_at: UtcDateTime
+2 -6
View File
@@ -1,7 +1,8 @@
"""Authentication service - password hashing, API key management.""" """Authentication service - password hashing, API key management."""
import hashlib import hashlib
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from src.backend.utils.tempo import utcnow
import bcrypt import bcrypt
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -78,11 +79,6 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
return supervisor return supervisor
def utcnow() -> datetime:
"""Naive UTC now (the whole DB moves to naive UTC, #42)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def hash_api_key(key: str) -> str: def hash_api_key(key: str) -> str:
"""SHA-256 hex digest of an API key; this is all the DB ever stores.""" """SHA-256 hex digest of an API key; this is all the DB ever stores."""
return hashlib.sha256(key.encode("utf-8")).hexdigest() return hashlib.sha256(key.encode("utf-8")).hexdigest()
+2 -1
View File
@@ -1,6 +1,7 @@
"""Measurement service - pass/fail calculation, data storage.""" """Measurement service - pass/fail calculation, data storage."""
import math import math
from datetime import datetime from datetime import datetime
from src.backend.utils.tempo import utcnow
from decimal import Decimal from decimal import Decimal
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -343,7 +344,7 @@ async def authorise_measurement(
) -> Measurement: ) -> Measurement:
"""Let an out-of-tolerance value stand, on the supervisor's name.""" """Let an out-of-tolerance value stand, on the supervisor's name."""
measurement.supervisor_id = supervisor.id measurement.supervisor_id = supervisor.id
measurement.authorised_at = datetime.now() measurement.authorised_at = utcnow()
await db.flush() await db.flush()
await db.refresh(measurement) await db.refresh(measurement)
return measurement return measurement
@@ -14,6 +14,7 @@ import csv
import io import io
import logging import logging
from datetime import datetime from datetime import datetime
from src.backend.utils.tempo import iso_utc, to_local, utcnow
from pathlib import Path from pathlib import Path
from sqlalchemy import select, update from sqlalchemy import select, update
@@ -169,9 +170,9 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
measurement.input_method, measurement.input_method,
"" if measurement.input_duration_ms is None else measurement.input_duration_ms, "" if measurement.input_duration_ms is None else measurement.input_duration_ms,
measurement.measured_by, measurement.measured_by,
measurement.measured_at.isoformat() if measurement.measured_at else "", iso_utc(measurement.measured_at),
measurement.supervisor_id or "", measurement.supervisor_id or "",
measurement.authorised_at.isoformat() if measurement.authorised_at else "", iso_utc(measurement.authorised_at),
]) ])
return output.getvalue() return output.getvalue()
@@ -191,7 +192,7 @@ async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | N
directory = Path(settings.upload_path) / EXPORT_SUBDIR directory = Path(settings.upload_path) / EXPORT_SUBDIR
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S") stamp = to_local(run.closed_at or utcnow()).strftime("%Y%m%d_%H%M%S")
filename = f"production_{run.id}_{stamp}.csv" filename = f"production_{run.id}_{stamp}.csv"
# A large run's file can take a moment to write: off the event loop, so # A large run's file can take a moment to write: off the event loop, so
# closing one run does not stall every other request being served. # closing one run does not stall every other request being served.
+3 -2
View File
@@ -14,6 +14,7 @@ the countdown would be off by the UTC offset. The number is the contract; the
timestamp is only there for the audit trail. timestamp is only there for the audit trail.
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
from src.backend.utils.tempo import utcnow
from typing import Optional from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -34,10 +35,10 @@ from src.backend.services import (
def _now() -> datetime: def _now() -> datetime:
"""Single source of time for runs. """Single source of time for runs.
Naive local time, matching what the database writes for ``func.now()`` defaults, Naive UTC, matching what the database writes for ``func.now()`` defaults,
so a run's started_at and its deadline are comparable. so a run's started_at and its deadline are comparable.
""" """
return datetime.now() return utcnow()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+13 -7
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import base64 import base64
from datetime import datetime from datetime import datetime
from src.backend.utils.tempo import to_local, utcnow
from pathlib import Path from pathlib import Path
import plotly.graph_objects as go import plotly.graph_objects as go
@@ -32,6 +33,11 @@ _jinja_env = Environment(
) )
def _giorno_locale(dt: datetime) -> str:
"""UTC naive filter bound -> local calendar day (YYYY-MM-DD)."""
return to_local(dt).strftime("%Y-%m-%d")
async def _query_measurements( async def _query_measurements(
db: AsyncSession, db: AsyncSession,
recipe_id: int, recipe_id: int,
@@ -323,9 +329,9 @@ async def generate_spc_report(
if version_id: if version_id:
filters_desc.append(f"Version: {version_id}") filters_desc.append(f"Version: {version_id}")
if date_from: if date_from:
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}") filters_desc.append(f"From: {_giorno_locale(date_from)}")
if date_to: if date_to:
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}") filters_desc.append(f"To: {_giorno_locale(date_to)}")
if lot_number: if lot_number:
filters_desc.append(f"Lot: {lot_number}") filters_desc.append(f"Lot: {lot_number}")
if serial_number: if serial_number:
@@ -342,7 +348,7 @@ async def generate_spc_report(
control_chart_svg=control_chart_svg, control_chart_svg=control_chart_svg,
histogram_svg=histogram_svg, histogram_svg=histogram_svg,
filters_desc=filters_desc, filters_desc=filters_desc,
generated_at=datetime.now(), generated_at=to_local(utcnow()),
n_measurements=len(measurements), n_measurements=len(measurements),
) )
@@ -395,7 +401,7 @@ async def generate_measurement_report(
"value": f"{float(m.value):.4f}", "value": f"{float(m.value):.4f}",
"unit": st_info.get("unit", "mm"), "unit": st_info.get("unit", "mm"),
"pass_fail": m.pass_fail, "pass_fail": m.pass_fail,
"measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"), "measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"), "operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
"lot_number": m.lot_number or "—", "lot_number": m.lot_number or "—",
"serial_number": m.serial_number or "—", "serial_number": m.serial_number or "—",
@@ -414,9 +420,9 @@ async def generate_measurement_report(
if version_id: if version_id:
filters_desc.append(f"Version: {version_id}") filters_desc.append(f"Version: {version_id}")
if date_from: if date_from:
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}") filters_desc.append(f"From: {_giorno_locale(date_from)}")
if date_to: if date_to:
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}") filters_desc.append(f"To: {_giorno_locale(date_to)}")
if lot_number: if lot_number:
filters_desc.append(f"Lot: {lot_number}") filters_desc.append(f"Lot: {lot_number}")
if serial_number: if serial_number:
@@ -430,7 +436,7 @@ async def generate_measurement_report(
rows=rows, rows=rows,
summary=summary, summary=summary,
filters_desc=filters_desc, filters_desc=filters_desc,
generated_at=datetime.now(), generated_at=to_local(utcnow()),
n_measurements=len(measurements), n_measurements=len(measurements),
) )
+113
View File
@@ -0,0 +1,113 @@
"""UTC in the DB, explicit +00:00 on the wire, local time only on presentation (#42)."""
from datetime import datetime, timezone
from unittest.mock import patch
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.user import User
from src.backend.services import report_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.utils.tempo import as_utc_naive, to_local, utcnow
def test_utcnow_naive_and_close_to_utc():
now = utcnow()
assert now.tzinfo is None
delta = datetime.now(timezone.utc).replace(tzinfo=None) - now
assert abs(delta.total_seconds()) < 5
def test_as_utc_naive():
parsed = datetime.fromisoformat("2026-10-06T10:00:00+02:00")
assert as_utc_naive(parsed) == datetime(2026, 10, 6, 8, 0)
assert as_utc_naive(parsed).tzinfo is None
naive = datetime(2026, 10, 6, 8, 0)
assert as_utc_naive(naive) is naive
assert as_utc_naive(None) is None
def test_to_local_is_rome():
assert to_local(datetime(2026, 10, 6, 8, 0)).strftime("%H:%M %z") == "10:00 +0200"
assert to_local(datetime(2026, 1, 6, 8, 0)).strftime("%H:%M %z") == "09:00 +0100"
async def _make_measurement(client, user, recipe_id) -> None:
tasks = (await client.get(f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user))).json()
await client.post(
"/api/measurements/",
headers=auth_headers(user),
json={
"subtask_id": tasks[0]["subtasks"][0]["id"],
"version_id": tasks[0]["version_id"],
"value": 10.0,
},
)
async def test_api_datetimes_carry_utc_offset_and_filter_equivalence(
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
await _make_measurement(client, measurement_tec_user, recipe.id)
resp = await client.get("/api/measurements/", headers=auth_headers(metrologist_user))
item = resp.json()["items"][0]
assert item["measured_at"].endswith("+00:00")
# Same instant written with +02:00 and as UTC: identical results.
future_utc = "2099-01-01T08:00:00+00:00"
future_rome = "2099-01-01T10:00:00+02:00"
past_utc = "2000-01-01T08:00:00+00:00"
past_rome = "2000-01-01T10:00:00+02:00"
for frm, to in ((past_utc, future_utc), (past_rome, future_rome)):
r = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
params={"date_from": frm, "date_to": to},
)
assert r.status_code == 200
assert r.json()["total"] == 1
# Boundary: measurement is "now" in UTC; a date_from 1h ahead in UTC,
# written as +02:00 (now+3h local wall clock), must exclude it.
ahead = (utcnow().replace(microsecond=0)).replace(tzinfo=timezone.utc)
from datetime import timedelta
ahead_utc = (ahead + timedelta(hours=1)).isoformat()
ahead_rome = (ahead + timedelta(hours=1)).astimezone(
timezone(timedelta(hours=2))
).isoformat()
totals = []
for frm in (ahead_utc, ahead_rome):
r = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
params={"date_from": frm},
)
totals.append(r.json()["total"])
assert totals == [0, 0]
async def test_report_generated_at_is_local_time(
db_session: AsyncSession, metrologist_user: User
):
recipe = await create_test_recipe(db_session, metrologist_user.id)
captured: dict[str, str] = {}
def fake_render(html: str) -> bytes:
captured["html"] = html
return b"%PDF"
with patch.object(report_service, "utcnow", return_value=datetime(2026, 10, 6, 8, 0)), \
patch.object(report_service, "_render_pdf", side_effect=fake_render):
await report_service.generate_measurement_report(db_session, recipe.id)
assert "Generated 2026-10-06 10:00" in captured["html"]
def test_report_filter_day_is_local():
"""UTC naive 22:00 of Oct 5 is already Oct 6 in Europe/Rome (#42)."""
assert report_service._giorno_locale(datetime(2026, 10, 5, 22, 0, 0)) == "2026-10-06"
View File
+34
View File
@@ -0,0 +1,34 @@
"""Time helpers: the DB holds naive UTC, presentation converts to display_timezone (#42)."""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from src.backend.config import settings
def utcnow() -> datetime:
"""Naive UTC now (the whole DB moves to naive UTC, #42)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def as_utc_naive(dt: datetime | None) -> datetime | None:
"""Aware -> UTC naive; naive is taken as UTC already; None stays None."""
if dt is None or dt.tzinfo is None:
return dt
return dt.astimezone(timezone.utc).replace(tzinfo=None)
def to_utc_aware(dt: datetime) -> datetime:
"""Naive (UTC) or aware datetime -> aware UTC."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def iso_utc(dt: datetime | None) -> str:
"""ISO 8601 with explicit +00:00; "" for None (CSV cells)."""
return to_utc_aware(dt).isoformat() if dt else ""
def to_local(dt: datetime) -> datetime:
"""Naive UTC (or aware) datetime -> aware datetime in settings.display_timezone."""
return to_utc_aware(dt).astimezone(ZoneInfo(settings.display_timezone))
+22 -1
View File
@@ -2,7 +2,8 @@
import json import json
import os import os
import re import re
from datetime import date from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from urllib.parse import urlparse from urllib.parse import urlparse
from flask import Flask, flash, g, jsonify, redirect, url_for, session, request from flask import Flask, flash, g, jsonify, redirect, url_for, session, request
@@ -154,6 +155,26 @@ def create_app() -> Flask:
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped) bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
return Markup(bolded.replace("\n", "<br>")) return Markup(bolded.replace("\n", "<br>"))
@app.template_filter("local_dt")
def local_dt_filter(value, fmt="%d/%m/%Y %H:%M"):
"""Show a UTC datetime (ISO string or datetime) in DISPLAY_TIMEZONE.
A value without timezone is taken as UTC. None or empty gives "-".
"""
if value is None or value == "":
return "-"
if isinstance(value, str):
try:
value = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return value
if not isinstance(value, datetime):
return str(value)
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
tz = ZoneInfo(app.config.get("DISPLAY_TIMEZONE") or "Europe/Rome")
return value.astimezone(tz).strftime(fmt)
@app.template_filter("tojson_attr") @app.template_filter("tojson_attr")
def tojson_attr_filter(value): def tojson_attr_filter(value):
"""JSON encode safe for HTML attributes (x-data, etc.). """JSON encode safe for HTML attributes (x-data, etc.).
+67 -1
View File
@@ -1,8 +1,10 @@
"""MeasurementTec blueprint - recipe selection and measurement execution.""" """MeasurementTec blueprint - recipe selection and measurement execution."""
import urllib.parse import urllib.parse
import requests as http_requests
from flask import ( from flask import (
Blueprint, abort, flash, jsonify, redirect, render_template, Blueprint, Response, abort, flash, jsonify, redirect, render_template,
request, session, url_for, request, session, url_for,
) )
from flask_babel import gettext as _ from flask_babel import gettext as _
@@ -820,3 +822,67 @@ def api_close_production(run_id: int):
def api_get_file(file_path: str): def api_get_file(file_path: str):
"""Proxy: Serve file from API server (browser can't send X-API-Key).""" """Proxy: Serve file from API server (browser can't send X-API-Key)."""
return proxy_file(file_path) return proxy_file(file_path)
# ---------------------------------------------------------------------------
# Route: vision execute (#46) - the browser sends an image, we relay it to
# POST /api/vision/execute (multipart) because it cannot send X-API-Key itself.
# ---------------------------------------------------------------------------
@measure_bp.route("/api/vision/execute", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_vision_execute():
"""Proxy: run the task's VisionSuite recipe on the uploaded image."""
image = request.files.get("image")
task_id = request.form.get("task_id")
if image is None or not task_id:
return jsonify({
"error": True,
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
}), 400
# Same context fields as the manual flow; empty ones are left out so the
# backend sees them as absent rather than as "".
form = {"task_id": task_id}
for key in ("task_type", "lot_number", "serial_number", "production_run_id"):
val = request.form.get(key)
if val:
form[key] = val
form.setdefault("lot_number", session.get("lot_number", ""))
form.setdefault("serial_number", session.get("serial_number", ""))
resp = api_client.post(
"/api/vision/execute",
data=form,
files={"image": (
image.filename or "capture.jpg",
image.read(),
image.mimetype or "application/octet-stream",
)},
)
if resp.get("error"):
status_code = resp.get("status_code", 500)
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
if resp.get("session_expired"):
body["session_expired"] = True
return jsonify(body), status_code if status_code >= 400 else 500
return jsonify(resp), 200
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_vision_result_image(result_id: int):
"""Proxy: the image saved with a vision result (needs X-API-Key)."""
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/vision/results/{result_id}/image",
headers={"X-API-Key": session.get("api_key", "")},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
+2
View File
@@ -56,6 +56,8 @@ class Config:
# Babel i18n # Babel i18n
BABEL_DEFAULT_LOCALE = "it" BABEL_DEFAULT_LOCALE = "it"
BABEL_DEFAULT_TIMEZONE = "Europe/Rome" BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
# Il DB e le API parlano UTC; si converte solo alla presentazione.
DISPLAY_TIMEZONE: str = os.getenv("DISPLAY_TIMEZONE", "Europe/Rome")
LANGUAGES = {"it": "Italiano", "en": "English"} LANGUAGES = {"it": "Italiano", "en": "English"}
@@ -271,7 +271,7 @@ function csvExport() {
*/ */
generateFilename(prefix) { generateFilename(prefix) {
const now = new Date(); const now = new Date();
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // YYYY-MM-DD locale
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
return `${prefix}_${dateStr}_${timeStr}.csv`; return `${prefix}_${dateStr}_${timeStr}.csv`;
@@ -0,0 +1,125 @@
/**
* Vision execute - Alpine component for camera_measure / dxf_compare tasks (#46).
*
* The operator takes (or picks) an image, `acquire(blob)` sends it to the Flask
* proxy, and the result - quote table, outcome, failures, overlay - is shown on
* the image. `acquire(blob)` is the single entry point on purpose: the agent
* camera (#3) will call it with its own blob and reuse everything below.
*
* Config comes from window.__visionCfg (set by the template):
* executeUrl, imageUrlTpl (with a 0 placeholder for the result id), taskId,
* taskType, subtasks, labels.
* Run context (lot, serial, production run) is read lazily through `ctx()`,
* which the template binds to the parent task component.
*/
function visionExecute() {
// Overlay colour per role (VisionSuite roles: reference, roi, found).
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
return {
busy: false,
error: '',
result: null, // last execute response
imageSrc: '', // object URL of the acquired image (shown under the overlay)
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
// Entry point shared with the future agent camera.
async acquire(blob) {
if (!blob || this.busy) return;
const cfg = window.__visionCfg || {};
this.busy = true;
this.error = '';
this.result = null;
// Show the picked image at once: the overlay is drawn on top when the result arrives.
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageSrc = URL.createObjectURL(blob);
try {
const c = this.ctx() || {};
const fd = new FormData();
fd.append('task_id', cfg.taskId);
fd.append('task_type', cfg.taskType || '');
fd.append('image', blob, blob.name || 'capture.jpg');
fd.append('lot_number', c.lot || '');
fd.append('serial_number', c.serial || '');
if (c.runId) fd.append('production_run_id', c.runId);
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
const resp = await fetch(cfg.executeUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok || body.error) {
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return;
}
this.result = body;
// Draw once Alpine has rendered the canvas.
this.$nextTick(() => this.drawOverlay());
} catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
} finally {
this.busy = false;
}
},
// Input change: take the file and allow picking the same one again.
onPick(ev) {
const f = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (f) this.acquire(f);
},
// Image shown: the saved one from the server when available, else the local one.
get shownImage() {
const cfg = window.__visionCfg || {};
if (this.result && this.result.vision_result_id && cfg.imageUrlTpl) {
return cfg.imageUrlTpl.replace('/0/', '/' + this.result.vision_result_id + '/');
}
return this.imageSrc;
},
// Quote rows: server measurements joined with the subtask names.
get rows() {
const cfg = window.__visionCfg || {};
const subs = cfg.subtasks || [];
return ((this.result && this.result.measurements) || []).map((m) => {
const st = subs.find((s) => s.id === m.subtask_id) || {};
return {
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
value: m.value,
unit: st.unit || 'mm',
status: m.pass_fail,
};
});
},
statusClass(s) {
// Same palette as the measurement markers of the manual flow.
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
if (s === 'fail') return 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40';
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
},
// Canvas is sized to the natural image, so overlay coordinates (image px)
// are used as they are and CSS scales the whole thing to the displayed size.
drawOverlay() {
const img = this.$refs.img, cv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineWidth = Math.max(2, cv.width / 400);
g.lineJoin = 'round';
for (const item of ((this.result && this.result.overlay) || [])) {
const rows = item.rows || [], cols = item.cols || [];
if (!rows.length || rows.length !== cols.length) continue;
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
g.beginPath();
g.moveTo(cols[0], rows[0]);
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
g.stroke();
}
},
};
}
@@ -13,6 +13,15 @@
barcodeInput: '', barcodeInput: '',
barcodeLoading: false, barcodeLoading: false,
barcodeError: '', barcodeError: '',
/* #63/#64: ricetta scelta che richiede lotto/seriale; il pannello compare solo allora. */
selected: null,
needsTrace(recipe) {
return !!(recipe && (recipe.requires_lot || recipe.requires_serial));
},
choose(recipe) {
this.selected = recipe;
this.$nextTick(() => { const el = document.getElementById('trace-panel'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); });
},
get filteredRecipes() { get filteredRecipes() {
if (!this.search) return this.recipes; if (!this.search) return this.recipes;
const q = this.search.toLowerCase(); const q = this.search.toLowerCase();
@@ -62,9 +71,12 @@
// The scanner is a way in like any other: a recipe that demands a lot // The scanner is a way in like any other: a recipe that demands a lot
// demands it here too, or the rule would last exactly as long as it // demands it here too, or the rule would last exactly as long as it
// took someone to reach for the barcode reader. // took someone to reach for the barcode reader.
const missing = this.missingTraceability(data); // #63/#64: se la ricetta chiede lotto/seriale mancanti, la si sceglie e
if (missing.length) { // compare il pannello con i soli campi richiesti.
this.barcodeError = {{ _("Compila prima:")|tojson_attr }} + ' ' + missing.join(', '); if (this.missingTraceability(data).length) {
this.barcodeModal = false;
this.barcodeInput = '';
this.choose(data);
return; return;
} }
this.barcodeModal = false; this.barcodeModal = false;
@@ -149,50 +161,6 @@
</div> </div>
</div> </div>
<!-- Traceability (data entry, NOT a search filter) -->
<div class="tmf-card mb-6 border-l-4 border-l-primary">
<div class="p-4 sm:p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<div>
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}</h2>
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Lot Number -->
<div>
<label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-amber-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
{{ _('Numero Lotto') }}
</label>
<input type="text"
x-model="lot_number"
placeholder="{{ _('Es. LOT-2026-001') }}"
class="tmf-input font-mono">
</div>
<!-- Serial Number -->
<div>
<label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
{{ _('Numero Seriale') }}
</label>
<input type="text"
x-model="serial_number"
placeholder="{{ _('Es. SN-000123') }}"
class="tmf-input font-mono">
</div>
</div>
</div>
</div>
<!-- Results Count --> <!-- Results Count -->
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<p class="text-sm text-[var(--text-secondary)]"> <p class="text-sm text-[var(--text-secondary)]">
@@ -269,30 +237,25 @@
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)] <div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
rounded-b-xl"> rounded-b-xl">
{# What this recipe insists on before anyone measures. Said on the card {# #63/#64: una ricetta con lotto/seriale obbligatori si sceglie prima
rather than after the fact: a measurement taken without its lot cannot (il pannello compare sotto la griglia); le altre partono subito. #}
be traced back, and finding that out later is finding it out too late. #} <template x-if="needsTrace(recipe)">
<template x-if="missingTraceability(recipe).length"> <button type="button" @click="choose(recipe)"
<p class="mb-2 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-300"> :class="selected && selected.id === recipe.id ? 'ring-2 ring-primary' : ''"
<svg class="w-3.5 h-3.5 shrink-0 mt-px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> class="btn btn-primary w-full justify-center text-sm font-semibold">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/> {{ _('Seleziona') }}
</svg> </button>
<span x-text="{{ _('Compila prima:')|tojson_attr }} + ' ' + missingTraceability(recipe).join(', ')"></span>
</p>
</template> </template>
<template x-if="!needsTrace(recipe)">
<a :href="missingTraceability(recipe).length ? null : buildTaskUrl(recipe.id)" <a :href="buildTaskUrl(recipe.id)"
:aria-disabled="missingTraceability(recipe).length > 0"
:class="missingTraceability(recipe).length
? 'opacity-40 cursor-not-allowed pointer-events-none'
: 'group-hover:shadow-md'"
class="btn btn-primary w-full justify-center text-sm font-semibold class="btn btn-primary w-full justify-center text-sm font-semibold
transition-shadow duration-200"> transition-shadow duration-200 group-hover:shadow-md">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg> </svg>
{{ _('AVVIA IN SEQUENZA') }} {{ _('AVVIA ATTIVITÀ') }}
</a> </a>
</template>
{# The whole list, for when the operator wants to see it or go back to a {# The whole list, for when the operator wants to see it or go back to a
task left unfinished. Deliberately the quiet one of the two. #} task left unfinished. Deliberately the quiet one of the two. #}
@@ -306,6 +269,41 @@
</template> </template>
</div> </div>
<!-- Traceability (#63/#64): compare solo dopo aver scelto una ricetta con
requires_lot / requires_serial, con i soli campi richiesti. -->
<div id="trace-panel" x-show="selected && needsTrace(selected)" x-cloak
class="tmf-card mt-6 border-l-4 border-l-primary">
<div class="p-4 sm:p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<div>
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}: <span x-text="selected ? selected.name : ''"></span></h2>
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div x-show="selected && selected.requires_lot">
<label class="tmf-label">{{ _('Numero Lotto') }}</label>
<input type="text" x-model="lot_number"
placeholder="{{ _('Es. LOT-2026-001') }}" class="tmf-input font-mono">
</div>
<div x-show="selected && selected.requires_serial">
<label class="tmf-label">{{ _('Numero Seriale') }}</label>
<input type="text" x-model="serial_number"
placeholder="{{ _('Es. SN-000123') }}" class="tmf-input font-mono">
</div>
</div>
<a :href="selected && !missingTraceability(selected).length ? buildTaskUrl(selected.id) : null"
:aria-disabled="!selected || missingTraceability(selected).length > 0"
:class="(!selected || missingTraceability(selected).length) ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''"
class="btn btn-primary w-full justify-center text-sm font-semibold mt-4">
{{ _('AVVIA ATTIVITÀ') }}
</a>
</div>
</div>
<!-- Empty State --> <!-- Empty State -->
<div x-show="filteredRecipes.length === 0" x-cloak <div x-show="filteredRecipes.length === 0" x-cloak
class="text-center py-16"> class="text-center py-16">
@@ -183,7 +183,7 @@
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %} {% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
<tr> <tr>
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);"> <td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }} {{ m.measured_at|local_dt }}
</td> </td>
<td style="color: var(--text-secondary);" class="text-sm"> <td style="color: var(--text-secondary);" class="text-sm">
{{ m.task_info.title if m.task_info else '-' }} {{ m.task_info.title if m.task_info else '-' }}
@@ -115,20 +115,12 @@
</svg> </svg>
{{ _('Lista task') }} {{ _('Lista task') }}
</a> </a>
<button @click="goToNextTask()" {# #60: un solo «Completato», quello grande in fondo (touch); qui resta solo la lista. #}
:disabled="!canLeaveTask"
:title="canLeaveTask ? '' : blockedReason"
:class="canLeaveTask ? '' : 'opacity-40 cursor-not-allowed'"
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
</button>
</div> </div>
{# Fermo linea + Fine produzione (measurement tasks only) #} {# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="isMeasureTask"> <template x-if="isMeasureTask && productionRun">
{# #61: Fermo linea / Fine produzione solo con una produzione aperta #}
<div class="shrink-0 flex items-center gap-1.5"> <div class="shrink-0 flex items-center gap-1.5">
{# While the line is stopped this becomes the way to restart it, so the {# While the line is stopped this becomes the way to restart it, so the
capoturno never has to hunt for a second control. #} capoturno never has to hunt for a second control. #}
@@ -191,7 +183,8 @@
{# ================================================================ {# ================================================================
MAIN — 3-column layout MAIN — 3-column layout
================================================================ #} ================================================================ #}
<div class="flex-1 flex overflow-hidden"> {# #46: camera_measure / dxf_compare get the acquisition panel below instead of the manual layout #}
<div class="flex-1 flex overflow-hidden" x-show="!isVisionTask">
{# ────────────────────────────────────────────── {# ──────────────────────────────────────────────
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks) LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
@@ -451,6 +444,63 @@
</div> </div>
{# ================================================================
VISION PANEL (#46) - acquire an image, run the recipe server-side, show the
quotes and the overlay. acquire(blob) is the entry point the agent camera
(#3) will reuse.
================================================================ #}
<div x-show="isVisionTask" x-cloak
x-data="visionExecute()"
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
<div class="flex-1 min-w-0 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
<input type="file" accept="image/*" capture="environment" class="hidden"
@change="onPick($event)" :disabled="busy">
</label>
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
</div>
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div>
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
</div>
</div>
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
<div class="text-sm font-bold text-[var(--text-primary)]">
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
</div>
<table class="w-full text-sm" x-show="rows.length">
<template x-for="r in rows" :key="r.label">
<tr>
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
</tr>
</template>
</table>
<div x-show="result && result.failures && result.failures.length"
class="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-xs">
<div class="font-bold mb-1">{{ _('Strumenti falliti') }}</div>
<template x-for="f in ((result && result.failures) || [])" :key="f.tool_id">
<div><span class="font-mono" x-text="f.tool_name || f.tool_id"></span>: <span x-text="f.error"></span></div>
</template>
</div>
</div>
</div>
{# ================================================================ {# ================================================================
MEASUREMENT TIMER BANNER MEASUREMENT TIMER BANNER
================================================================ #} ================================================================ #}
@@ -698,10 +748,12 @@
</div> </div>
</template> </template>
<template x-if="!isMeasureTask || subtasks.length === 0"> <template x-if="!isMeasureTask || subtasks.length === 0">
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
<button @click="goToNextTask()" <button @click="goToNextTask()"
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md :disabled="!canLeaveTask"
class="btn text-lg font-bold flex-1 w-full min-h-[64px] justify-center gap-2 shadow-md
bg-measure-pass text-white hover:opacity-90"> bg-measure-pass text-white hover:opacity-90">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg> </svg>
{{ _('Completato') }} {{ _('Completato') }}
@@ -787,6 +839,10 @@
</div> </div>
</div> </div>
{# #61: se il server rifiuta l'avvio produzione, l'errore si vede qui, nell'overlay #}
<p x-show="productionError" x-cloak
class="mb-2 text-sm text-red-700 dark:text-red-300 text-center" x-text="productionError"></p>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="goToSummary()" <button @click="goToSummary()"
class="btn btn-secondary flex-1 justify-center gap-2"> class="btn btn-secondary flex-1 justify-center gap-2">
@@ -912,6 +968,18 @@
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script> <script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script> <script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script> <script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
{# #46: acquisition + overlay for camera_measure / dxf_compare #}
<script>
window.__visionCfg = {
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
taskId: {{ task.id|tojson }},
taskType: {{ (task.task_type or '')|tojson }},
subtasks: window.__taskSubtasks,
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
};
</script>
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
{# The clock's rules - how a delay reads, when the buzzer goes, where the {# The clock's rules - how a delay reads, when the buzzer goes, where the
measurement lives - are shared with the other screens rather than restated. #} measurement lives - are shared with the other screens rather than restated. #}
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script> <script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
@@ -1180,11 +1248,18 @@ function taskExecute() {
async loadProductionRun() { async loadProductionRun() {
try { try {
const resp = await fetch('{{ url_for("measure.api_current_production") }}'); const resp = await fetch('{{ url_for("measure.api_current_production") }}');
if (!resp.ok) return; if (!resp.ok) {
// #61: si mostra il detail del server (es. 503 "Stazione non configurata"),
// che e' la causa vera; fallback generico se il corpo non e' JSON.
const errBody = await resp.json().catch(() => null);
this.productionError = (errBody && errBody.detail) || '{{ _("Errore di comunicazione con il server") }}';
return;
}
const run = await resp.json(); const run = await resp.json();
if (run && run.id) this.adoptProductionRun(run); if (run && run.id) this.adoptProductionRun(run);
} catch (e) { } catch (e) {
// Offline or server down: leave the page usable, just without the timer. // #61: offline o server giu: pagina usabile, ma l'errore e' visibile.
this.productionError = '{{ _("Errore di connessione") }}';
} }
}, },
@@ -1697,13 +1772,9 @@ function taskExecute() {
this.showCompletionOverlay = false; this.showCompletionOverlay = false;
const opened = await this.startProduction(); const opened = await this.startProduction();
if (!opened) { if (!opened) {
// Server refused: keep the operator working rather than stranding them // #61: server rifiuta -> niente timer locale finto; si riapre l'overlay
// mid-shift, but productionError is on screen so it is not silent. // con l'errore (productionError) cosi' l'operatore puo' riprovare.
this.cycleConfirmed = true; this.showCompletionOverlay = true;
this.cycleCount++;
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
}
return; return;
} }
// The START-phase measurements have just been taken: record them as the first // The START-phase measurements have just been taken: record them as the first
@@ -1751,6 +1822,12 @@ function taskExecute() {
// ---- Open/close supervisor modal (always reset fields) ---- // ---- Open/close supervisor modal (always reset fields) ----
openSupervisorModal(action) { openSupervisorModal(action) {
// #61: fermo/ripresa/fine produzione agiscono su un run: senza run aperto
// si dice subito, prima di chiedere le credenziali.
if (['fermo_linea', 'ripresa', 'fine_produzione'].includes(action) && !this.productionRun) {
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
return;
}
this.supervisorAction = action; this.supervisorAction = action;
this.supervisorUsername = ''; this.supervisorUsername = '';
this.supervisorPassword = ''; this.supervisorPassword = '';
@@ -1776,6 +1853,12 @@ function taskExecute() {
return declared === 'measure' || declared === 'camera_measure'; return declared === 'measure' || declared === 'camera_measure';
}, },
/* #46: image-based tasks run a VisionSuite recipe on the server. */
get isVisionTask() {
const t = this.task.task_type;
return t === 'camera_measure' || t === 'dxf_compare';
},
/* A measurement task with no quotes yet: the recipe is unfinished. Said out /* A measurement task with no quotes yet: the recipe is unfinished. Said out
loud rather than silently rendering an empty screen. */ loud rather than silently rendering an empty screen. */
get isMeasureTaskWithoutQuotes() { get isMeasureTaskWithoutQuotes() {
@@ -104,7 +104,7 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg> </svg>
{{ _('AVVIA IN SEQUENZA') }} {{ _('AVVIA ATTIVITÀ') }}
</a> </a>
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -312,8 +312,8 @@ function spcDashboard() {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId); params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId); if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00'); if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59'); if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
try { try {
// Parallel fetch of all endpoints // Parallel fetch of all endpoints
@@ -417,8 +417,8 @@ function spcDashboard() {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId); params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId); if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00'); if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59'); if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
const url = '/statistics/api/report-' + type + '?' + params.toString(); const url = '/statistics/api/report-' + type + '?' + params.toString();
const resp = await fetch(url); const resp = await fetch(url);
@@ -0,0 +1,31 @@
"""Filtro local_dt: il backend parla UTC, la presentazione e' in Europe/Rome (#42)."""
from datetime import datetime, timezone
import pytest
@pytest.fixture
def local_dt(flask_app):
return flask_app.jinja_env.filters["local_dt"]
def test_summer_time(local_dt):
assert local_dt("2026-10-06T08:00:00+00:00") == "06/10/2026 10:00"
def test_winter_time(local_dt):
assert local_dt("2026-01-15T08:00:00+00:00") == "15/01/2026 09:00"
def test_naive_is_utc(local_dt):
assert local_dt("2026-10-06T08:00:00") == "06/10/2026 10:00"
def test_datetime_object_and_format(local_dt):
dt = datetime(2026, 10, 6, 8, 0, tzinfo=timezone.utc)
assert local_dt(dt, "%H:%M") == "10:00"
@pytest.mark.parametrize("empty", [None, ""])
def test_empty(local_dt, empty):
assert local_dt(empty) == "-"
@@ -407,6 +407,21 @@ class TestTaskComplete:
assert resp.status_code == 200 assert resp.status_code == 200
assert b"productionClock(" in resp.data assert b"productionClock(" in resp.data
def test_measured_at_shown_in_local_time(self, logged_in_client, mock_api_client):
"""#42: the API sends UTC; the summary shows Europe/Rome."""
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
{"id": 1, "marker_number": 1, "description": "d", "nominal": 1.0,
"unit": "mm"}]}],
None,
{"items": [{"id": 1, "subtask_id": 1, "value": 1.0, "pass_fail": "pass",
"measured_at": "2026-10-06T08:00:00+00:00"}]},
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
assert "06/10/2026 10:00" in resp.get_data(as_text=True)
def test_measurements_filtered_by_lot_serial_and_operator( def test_measurements_filtered_by_lot_serial_and_operator(
self, logged_in_client, mock_api_client, self, logged_in_client, mock_api_client,
): ):
@@ -0,0 +1,86 @@
"""gitea:Adriano/TieMeasureFlow#46 - operator side of the vision tasks.
The backend of contract B is not merged yet: api_client is mocked.
"""
import io
from unittest.mock import MagicMock, patch
from tests.conftest import api_get_router
TASK = {
"id": 12, "recipe_id": 3, "version_id": 5, "task_type": "camera_measure",
"title": "Foto", "directive": None, "description": None, "file_path": None,
"file_type": None, "annotations_json": None, "subtasks": [],
}
RECIPE = {
"id": 3, "code": "REC-1", "name": "Ricetta",
"measurement_interval_minutes": 30, "allow_manual_input": True,
"current_version": {"id": 5, "version_number": 1},
}
def test_execute_page_has_acquire_input(logged_in_client, mock_api_client, monkeypatch):
"""camera_measure screen offers the file/camera input and the acquire entry point."""
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/12": TASK,
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
"/api/recipes/3": RECIPE,
})
html = logged_in_client.get("/measure/execute/12").get_data(as_text=True)
assert 'capture="environment"' in html
assert 'accept="image/*"' in html
assert "visionExecute()" in html
assert "/measure/api/vision/execute" in html
def test_vision_execute_proxy_forwards_multipart(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {
"vision_result_id": 7, "outcome": "ok", "overlay": [], "failures": [],
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
}
resp = logged_in_client.post(
"/measure/api/vision/execute",
data={
"task_id": "12", "lot_number": "L1", "serial_number": "S1",
"production_run_id": "4",
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
},
content_type="multipart/form-data",
)
assert resp.status_code == 200
assert resp.get_json()["vision_result_id"] == 7
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/execute"
assert kwargs["data"]["task_id"] == "12"
assert kwargs["data"]["lot_number"] == "L1"
assert kwargs["data"]["production_run_id"] == "4"
name, content, mime = kwargs["files"]["image"]
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
def test_vision_execute_proxy_requires_image(logged_in_client, mock_api_client):
resp = logged_in_client.post("/measure/api/vision/execute", data={"task_id": "12"})
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_vision_execute_proxy_relays_backend_error(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "bad"}
resp = logged_in_client.post(
"/measure/api/vision/execute",
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
content_type="multipart/form-data",
)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "bad"
def test_vision_result_image_proxy(logged_in_client):
fake = MagicMock(status_code=200, content=b"IMG", headers={"content-type": "image/jpeg"})
with patch("blueprints.measure.http_requests.get", return_value=fake) as g:
resp = logged_in_client.get("/measure/api/vision/results/7/image")
assert resp.status_code == 200 and resp.data == b"IMG"
assert g.call_args[0][0].endswith("/api/vision/results/7/image")
assert g.call_args[1]["headers"]["X-API-Key"] == "test-api-key-12345"
@@ -104,7 +104,7 @@ def test_the_selection_screen_starts_the_sequence(
def test_avvia_on_the_list_starts_the_sequence(logged_in_client, mock_api_client): def test_avvia_on_the_list_starts_the_sequence(logged_in_client, mock_api_client):
html = _list_page(logged_in_client, mock_api_client) html = _list_page(logged_in_client, mock_api_client)
assert "/measure/start/3" in html assert "/measure/start/3" in html
assert "AVVIA IN SEQUENZA" in html assert "AVVIA ATTIVITÀ" in html
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -163,6 +163,32 @@ def test_the_header_offers_completato_not_the_summary(
assert '@click="goToNextTask()"' in html assert '@click="goToNextTask()"' in html
def test_only_one_completato_and_it_is_big(
logged_in_client, mock_api_client, monkeypatch,
):
"""#60: «Completato» una sola volta (in basso), largo e alto per il touch."""
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
# the label as a button text on its own line (comments in the JS also name it)
assert [l.strip() for l in html.splitlines()].count("Completato") == 1
assert "min-h-[64px]" in html
def test_no_open_run_means_no_fermo_linea_and_no_fake_timer(
logged_in_client, mock_api_client, monkeypatch,
):
"""#61: senza produzione aperta i pulsanti non si vedono, la modale non si
apre e un rifiuto del server non avvia un timer locale finto."""
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
assert 'x-if="isMeasureTask && productionRun"' in html
assert "!this.productionRun" in html
# #61: loadProductionRun mostra il detail del server, non solo il messaggio generico
assert "errBody.detail" in html
assert "Nessuna produzione aperta su questa stazione" in html
# il vecchio ripiego locale non c'e' piu'
assert "this.startCountdownFrom(this.measurementIntervalMinutes * 60)" not in html.split(
"async startProductionFromOverlay()")[1].split("async confirmCycle")[0]
def test_fine_ciclo_is_shown_before_it_can_be_used( def test_fine_ciclo_is_shown_before_it_can_be_used(
logged_in_client, mock_api_client, monkeypatch, logged_in_client, mock_api_client, monkeypatch,
): ):
@@ -196,3 +222,27 @@ def test_the_operator_flow_never_links_to_the_statistics(
] ]
for html in pages: for html in pages:
assert "/statistics" not in html assert "/statistics" not in html
def test_trace_panel_only_for_recipes_that_require_it(
logged_in_client, mock_api_client, monkeypatch,
):
"""#63/#64: ricerca+griglia in alto, pannello lotto/seriale solo dopo la
scelta di una ricetta che lo richiede, con i soli campi richiesti."""
_italian(logged_in_client)
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "A", "name": "Con lotto", "requires_lot": True,
"requires_serial": False},
{"id": 2, "code": "B", "name": "Libera"},
]
html = logged_in_client.get("/measure/select").get_data(as_text=True)
assert 'id="trace-panel"' in html
assert 'x-show="selected && needsTrace(selected)"' in html
assert 'x-show="selected && selected.requires_lot"' in html
assert 'x-show="selected && selected.requires_serial"' in html
# la ricerca e la griglia precedono il pannello
assert html.index("Cerca ricetta") < html.index('id="trace-panel"')
assert html.index("Recipe Grid") < html.index('id="trace-panel"')
# le ricette senza obblighi partono direttamente
assert 'x-if="!needsTrace(recipe)"' in html
@@ -1862,8 +1862,8 @@ msgstr "No description available"
#: templates/measure/select_recipe.html:294 #: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107 #: templates/measure/task_list.html:107
msgid "AVVIA IN SEQUENZA" msgid "AVVIA ATTIVITÀ"
msgstr "START THE SEQUENCE" msgstr "START ACTIVITY"
# Maker - Task Editor # Maker - Task Editor
#: templates/measure/select_recipe.html:302 #: templates/measure/select_recipe.html:302
@@ -1864,8 +1864,8 @@ msgstr "Nessuna descrizione disponibile"
#: templates/measure/select_recipe.html:294 #: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107 #: templates/measure/task_list.html:107
msgid "AVVIA IN SEQUENZA" msgid "AVVIA ATTIVITÀ"
msgstr "AVVIA IN SEQUENZA" msgstr "AVVIA ATTIVITÀ"
# Maker - Task Editor # Maker - Task Editor
#: templates/measure/select_recipe.html:302 #: templates/measure/select_recipe.html:302
@@ -1825,7 +1825,7 @@ msgstr ""
#: templates/measure/select_recipe.html:294 #: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107 #: templates/measure/task_list.html:107
msgid "AVVIA IN SEQUENZA" msgid "AVVIA ATTIVITÀ"
msgstr "" msgstr ""
#: templates/measure/select_recipe.html:302 #: templates/measure/select_recipe.html:302