Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e3316957c | |||
| db5d686938 | |||
| a829bf3eb0 | |||
| 7ac291b8ac | |||
| 486e1288bf |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ from src.backend.models.api.production import (
|
|||||||
)
|
)
|
||||||
from src.backend.models.orm.production import ProductionRun
|
from src.backend.models.orm.production import ProductionRun
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.services import auth_service, production_service
|
from src.backend.models.api.monitor import MonitorRun
|
||||||
|
from src.backend.services import auth_service, monitor_service, production_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||||
|
|
||||||
@@ -81,6 +82,27 @@ async def get_current_production_run(
|
|||||||
return await _as_response(db, run) if run is not None else None
|
return await _as_response(db, run) if run is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_high_authority(user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Monitor of all stations (#57): admin or Supervisor (capoturno) only."""
|
||||||
|
if user.is_admin or user.has_role("Supervisor"):
|
||||||
|
return user
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Supervisor role required",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/open", response_model=list[MonitorRun])
|
||||||
|
async def list_open_production_runs(
|
||||||
|
user: User = Depends(_require_high_authority),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Every open run with timer, last event and recent measurements (#57).
|
||||||
|
|
||||||
|
Declared before /{run_id} so "open" is not parsed as a run id.
|
||||||
|
"""
|
||||||
|
return await monitor_service.list_open_runs(db)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
||||||
async def get_production_run(
|
async def get_production_run(
|
||||||
run_id: int,
|
run_id: int,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""API models for the live station monitor (#57)."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorSubtask(BaseModel):
|
||||||
|
"""Mean of the recent measurements of one quote, against its tolerance."""
|
||||||
|
|
||||||
|
subtask_id: int
|
||||||
|
description: str
|
||||||
|
unit: str
|
||||||
|
count: int
|
||||||
|
mean: float
|
||||||
|
ltl: Optional[float] = None
|
||||||
|
utl: Optional[float] = None
|
||||||
|
# True when ltl <= mean <= utl (a missing limit does not constrain).
|
||||||
|
in_tolerance: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorEvent(BaseModel):
|
||||||
|
event_type: str
|
||||||
|
created_at: datetime
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorRun(BaseModel):
|
||||||
|
id: int
|
||||||
|
station_code: str
|
||||||
|
station_name: str
|
||||||
|
recipe_code: str
|
||||||
|
recipe_name: str
|
||||||
|
version_number: int
|
||||||
|
operator: str
|
||||||
|
status: str
|
||||||
|
started_at: datetime
|
||||||
|
duration_seconds: int
|
||||||
|
cycle_count: int
|
||||||
|
seconds_to_next_measurement: Optional[int] = None
|
||||||
|
overdue: bool
|
||||||
|
last_event: Optional[MonitorEvent] = None
|
||||||
|
# Counts over the last MONITOR_WINDOW measurements of the run.
|
||||||
|
measured: int
|
||||||
|
pass_count: int
|
||||||
|
warning_count: int
|
||||||
|
fail_count: int
|
||||||
|
subtasks: list[MonitorSubtask]
|
||||||
|
server_time: datetime
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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."""
|
"""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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Live monitor of the open production runs (#57).
|
||||||
|
|
||||||
|
One query per table, whatever the number of runs: measurements and last events are
|
||||||
|
ranked per run with a window function and cut in the database, not in a loop.
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.api.monitor import MonitorEvent, MonitorRun, MonitorSubtask
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import production_service
|
||||||
|
|
||||||
|
# Recent measurements per run that feed counts and means (#57: last N).
|
||||||
|
MONITOR_WINDOW = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _in_tolerance(mean: float, ltl, utl) -> bool:
|
||||||
|
# A missing limit does not constrain.
|
||||||
|
if ltl is not None and mean < float(ltl):
|
||||||
|
return False
|
||||||
|
if utl is not None and mean > float(utl):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
|
||||||
|
runs = (await db.execute(
|
||||||
|
select(ProductionRun)
|
||||||
|
.where(ProductionRun.status.in_(("running", "paused")))
|
||||||
|
.order_by(ProductionRun.started_at)
|
||||||
|
)).scalars().all()
|
||||||
|
if not runs:
|
||||||
|
return []
|
||||||
|
run_ids = [r.id for r in runs]
|
||||||
|
|
||||||
|
# Operators: one query.
|
||||||
|
users = {
|
||||||
|
u.id: u for u in (await db.execute(
|
||||||
|
select(User).where(User.id.in_({r.operator_id for r in runs}))
|
||||||
|
)).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Last event per run: rank desc inside the run, keep rank 1.
|
||||||
|
ev_rank = func.row_number().over(
|
||||||
|
partition_by=ProductionEvent.run_id,
|
||||||
|
order_by=(ProductionEvent.created_at.desc(), ProductionEvent.id.desc()),
|
||||||
|
).label("rn")
|
||||||
|
ev_sub = select(
|
||||||
|
ProductionEvent.run_id.label("run_id"),
|
||||||
|
ProductionEvent.event_type.label("event_type"),
|
||||||
|
ProductionEvent.created_at.label("created_at"),
|
||||||
|
ProductionEvent.note.label("note"),
|
||||||
|
ev_rank,
|
||||||
|
).where(ProductionEvent.run_id.in_(run_ids)).subquery()
|
||||||
|
ev_rows = (await db.execute(
|
||||||
|
select(ev_sub.c.run_id, ev_sub.c.event_type, ev_sub.c.created_at, ev_sub.c.note)
|
||||||
|
.where(ev_sub.c.rn == 1)
|
||||||
|
)).all()
|
||||||
|
last_event = {r.run_id: r for r in ev_rows}
|
||||||
|
|
||||||
|
# Last N measurements per run, joined to their subtask for the limits.
|
||||||
|
m_rank = func.row_number().over(
|
||||||
|
partition_by=Measurement.production_run_id,
|
||||||
|
order_by=(Measurement.measured_at.desc(), Measurement.id.desc()),
|
||||||
|
).label("rn")
|
||||||
|
m_sub = select(
|
||||||
|
Measurement.production_run_id.label("run_id"),
|
||||||
|
Measurement.subtask_id.label("subtask_id"),
|
||||||
|
Measurement.value.label("value"),
|
||||||
|
Measurement.pass_fail.label("pass_fail"),
|
||||||
|
m_rank,
|
||||||
|
).where(Measurement.production_run_id.in_(run_ids)).subquery()
|
||||||
|
m_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
m_sub.c.run_id, m_sub.c.subtask_id, m_sub.c.value, m_sub.c.pass_fail,
|
||||||
|
RecipeSubtask.description, RecipeSubtask.unit,
|
||||||
|
RecipeSubtask.ltl, RecipeSubtask.utl,
|
||||||
|
)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.id == m_sub.c.subtask_id)
|
||||||
|
.where(m_sub.c.rn <= MONITOR_WINDOW)
|
||||||
|
)).all()
|
||||||
|
by_run = defaultdict(list)
|
||||||
|
for row in m_rows:
|
||||||
|
by_run[row.run_id].append(row)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for run in runs:
|
||||||
|
rows = by_run.get(run.id, [])
|
||||||
|
per_sub: dict[int, list] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
per_sub[row.subtask_id].append(row)
|
||||||
|
subtasks = []
|
||||||
|
for sid, group in sorted(per_sub.items()):
|
||||||
|
mean = sum(float(g.value) for g in group) / len(group)
|
||||||
|
first = group[0]
|
||||||
|
subtasks.append(MonitorSubtask(
|
||||||
|
subtask_id=sid, description=first.description, unit=first.unit,
|
||||||
|
count=len(group), mean=round(mean, 6),
|
||||||
|
ltl=float(first.ltl) if first.ltl is not None else None,
|
||||||
|
utl=float(first.utl) if first.utl is not None else None,
|
||||||
|
in_tolerance=_in_tolerance(mean, first.ltl, first.utl),
|
||||||
|
))
|
||||||
|
timer = production_service.describe(run)
|
||||||
|
ev = last_event.get(run.id)
|
||||||
|
operator = users.get(run.operator_id)
|
||||||
|
result.append(MonitorRun(
|
||||||
|
id=run.id,
|
||||||
|
station_code=run.station.code, station_name=run.station.name,
|
||||||
|
recipe_code=run.recipe.code, recipe_name=run.recipe.name,
|
||||||
|
version_number=run.version.version_number,
|
||||||
|
operator=(operator.display_name or operator.username) if operator else "",
|
||||||
|
status=run.status,
|
||||||
|
started_at=run.started_at,
|
||||||
|
duration_seconds=max(0, int((timer["server_time"] - run.started_at).total_seconds())),
|
||||||
|
cycle_count=run.cycle_count,
|
||||||
|
seconds_to_next_measurement=timer["seconds_to_next_measurement"],
|
||||||
|
overdue=timer["overdue"],
|
||||||
|
last_event=MonitorEvent(
|
||||||
|
event_type=ev.event_type, created_at=ev.created_at, note=ev.note,
|
||||||
|
) if ev else None,
|
||||||
|
measured=len(rows),
|
||||||
|
pass_count=sum(1 for g in rows if g.pass_fail == "pass"),
|
||||||
|
warning_count=sum(1 for g in rows if g.pass_fail == "warning"),
|
||||||
|
fail_count=sum(1 for g in rows if g.pass_fail == "fail"),
|
||||||
|
subtasks=subtasks,
|
||||||
|
server_time=timer["server_time"],
|
||||||
|
))
|
||||||
|
return result
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ the countdown would be off by the UTC offset. The number is the contract; the
|
|||||||
timestamp is only there for the audit trail.
|
timestamp is only there for the audit trail.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -34,10 +35,10 @@ from src.backend.services import (
|
|||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
"""Single source of time for runs.
|
"""Single source of time for runs.
|
||||||
|
|
||||||
Naive local time, matching what the database writes for ``func.now()`` defaults,
|
Naive UTC, matching what the database writes for ``func.now()`` defaults,
|
||||||
so a run's started_at and its deadline are comparable.
|
so a run's started_at and its deadline are comparable.
|
||||||
"""
|
"""
|
||||||
return datetime.now()
|
return utcnow()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""GET /api/production-runs/open - live monitor of the open runs (#57)."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
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.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.tests.conftest import _create_user, auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(db, user, code, status="running", overdue=False, open_=True):
|
||||||
|
"""A station plus a run on a fresh recipe (limits ltl 9.5 / utl 10.5)."""
|
||||||
|
station = Station(code=f"ST-{code}", name=f"Linea {code}", active=True, created_by=user.id)
|
||||||
|
db.add(station)
|
||||||
|
recipe = await create_test_recipe(db, user_id=user.id, code=f"REC-{code}")
|
||||||
|
await db.flush()
|
||||||
|
version = recipe.versions[0]
|
||||||
|
now = datetime.utcnow()
|
||||||
|
run = ProductionRun(
|
||||||
|
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||||
|
operator_id=user.id, status=status if open_ else "closed",
|
||||||
|
started_at=now - timedelta(hours=1),
|
||||||
|
measurement_interval_minutes=30,
|
||||||
|
next_measurement_at=now + (timedelta(minutes=-5) if overdue else timedelta(minutes=20)),
|
||||||
|
cycle_count=3,
|
||||||
|
active_station_id=station.id if open_ else None,
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.flush()
|
||||||
|
db.add(ProductionEvent(run_id=run.id, event_type="start", user_id=user.id))
|
||||||
|
sub = (await db.execute(
|
||||||
|
select(RecipeSubtask).join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
|
||||||
|
.where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return run, sub, version
|
||||||
|
|
||||||
|
|
||||||
|
def _m(run, sub, version, user, value, pf, minutes=0):
|
||||||
|
return Measurement(
|
||||||
|
subtask_id=sub.id, version_id=version.id, measured_by=user.id, value=value,
|
||||||
|
pass_fail=pf, production_run_id=run.id,
|
||||||
|
measured_at=datetime.utcnow() - timedelta(minutes=minutes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_requires_high_authority(client, db_session, measurement_tec_user):
|
||||||
|
resp = await client.get("/api/production-runs/open", headers=auth_headers(measurement_tec_user))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_lists_only_open_runs_with_means(client, db_session, admin_user):
|
||||||
|
sup = await _create_user(db_session, "capo", roles=["Supervisor"])
|
||||||
|
r1, s1, v1 = await _run(db_session, admin_user, "A", overdue=True)
|
||||||
|
r2, s2, v2 = await _run(db_session, admin_user, "B", status="paused")
|
||||||
|
await _run(db_session, admin_user, "C", open_=False)
|
||||||
|
# Run A: mean 10.0 (inside), run B: mean 10.8 (outside, one fail).
|
||||||
|
for i, (val, pf) in enumerate([(9.9, "pass"), (10.1, "pass")]):
|
||||||
|
db_session.add(_m(r1, s1, v1, admin_user, val, pf, i))
|
||||||
|
for i, (val, pf) in enumerate([(10.6, "fail"), (11.0, "fail"), (10.8, "warning")]):
|
||||||
|
db_session.add(_m(r2, s2, v2, admin_user, val, pf, i))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/api/production-runs/open", headers=auth_headers(sup))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = {r["station_code"]: r for r in resp.json()}
|
||||||
|
assert set(body) == {"ST-A", "ST-B"} # closed run C is absent
|
||||||
|
a, b = body["ST-A"], body["ST-B"]
|
||||||
|
assert a["overdue"] is True and a["seconds_to_next_measurement"] < 0
|
||||||
|
assert b["overdue"] is False and b["status"] == "paused"
|
||||||
|
assert a["duration_seconds"] >= 3600 and a["cycle_count"] == 3
|
||||||
|
assert a["last_event"]["event_type"] == "start"
|
||||||
|
assert a["pass_count"] == 2 and a["fail_count"] == 0
|
||||||
|
assert a["subtasks"][0]["mean"] == 10.0 and a["subtasks"][0]["in_tolerance"] is True
|
||||||
|
assert b["fail_count"] == 2 and b["warning_count"] == 1
|
||||||
|
assert round(b["subtasks"][0]["mean"], 3) == 10.8
|
||||||
|
assert b["subtasks"][0]["in_tolerance"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_empty_for_admin(client, admin_user):
|
||||||
|
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
|
||||||
|
assert resp.status_code == 200 and resp.json() == []
|
||||||
@@ -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"
|
||||||
@@ -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 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
|
||||||
@@ -73,12 +74,14 @@ def create_app() -> Flask:
|
|||||||
from blueprints.maker import maker_bp
|
from blueprints.maker import maker_bp
|
||||||
from blueprints.statistics import statistics_bp
|
from blueprints.statistics import statistics_bp
|
||||||
from blueprints.admin import admin_bp
|
from blueprints.admin import admin_bp
|
||||||
|
from blueprints.monitor import monitor_bp # #57 live station monitor
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(measure_bp, url_prefix="/measure")
|
app.register_blueprint(measure_bp, url_prefix="/measure")
|
||||||
app.register_blueprint(maker_bp, url_prefix="/maker")
|
app.register_blueprint(maker_bp, url_prefix="/maker")
|
||||||
app.register_blueprint(statistics_bp, url_prefix="/statistics")
|
app.register_blueprint(statistics_bp, url_prefix="/statistics")
|
||||||
app.register_blueprint(admin_bp, url_prefix="/admin")
|
app.register_blueprint(admin_bp, url_prefix="/admin")
|
||||||
|
app.register_blueprint(monitor_bp, url_prefix="/monitor") # #57
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def _check_secret_key():
|
def _check_secret_key():
|
||||||
@@ -154,6 +157,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.).
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Live monitor of the open production runs (#57).
|
||||||
|
|
||||||
|
Page at /monitor for admin and Supervisor (capoturno); the page polls a JSON proxy
|
||||||
|
of GET /api/production-runs/open every 10 seconds.
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, jsonify, render_template, session
|
||||||
|
|
||||||
|
from blueprints.auth import login_required
|
||||||
|
from services.api_client import api_client
|
||||||
|
|
||||||
|
monitor_bp = Blueprint("monitor", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_required(f):
|
||||||
|
"""Admin or Supervisor only: the same people the backend lets read /open."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
user = session.get("user", {})
|
||||||
|
if not (user.get("is_admin") or "Supervisor" in user.get("roles", [])):
|
||||||
|
abort(403)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
@monitor_bp.route("", strict_slashes=False)
|
||||||
|
@login_required
|
||||||
|
@monitor_required
|
||||||
|
def index():
|
||||||
|
return render_template("monitor/index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@monitor_bp.route("/api/runs")
|
||||||
|
@login_required
|
||||||
|
@monitor_required
|
||||||
|
def api_runs():
|
||||||
|
"""JSON proxy polled by the page."""
|
||||||
|
resp = api_client.get("/api/production-runs/open")
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
body = {"error": True, "detail": resp.get("detail", "")}
|
||||||
|
if resp.get("session_expired"):
|
||||||
|
body["session_expired"] = True
|
||||||
|
return jsonify(body), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp)
|
||||||
@@ -56,6 +56,8 @@ class Config:
|
|||||||
# Babel i18n
|
# Babel i18n
|
||||||
BABEL_DEFAULT_LOCALE = "it"
|
BABEL_DEFAULT_LOCALE = "it"
|
||||||
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
|
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
|
||||||
|
# Il DB e le API parlano UTC; si converte solo alla presentazione.
|
||||||
|
DISPLAY_TIMEZONE: str = os.getenv("DISPLAY_TIMEZONE", "Europe/Rome")
|
||||||
LANGUAGES = {"it": "Italiano", "en": "English"}
|
LANGUAGES = {"it": "Italiano", "en": "English"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ function csvExport() {
|
|||||||
*/
|
*/
|
||||||
generateFilename(prefix) {
|
generateFilename(prefix) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // YYYY-MM-DD locale
|
||||||
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
|
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
|
||||||
|
|
||||||
return `${prefix}_${dateStr}_${timeStr}.csv`;
|
return `${prefix}_${dateStr}_${timeStr}.csv`;
|
||||||
|
|||||||
@@ -183,7 +183,7 @@
|
|||||||
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
|
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
|
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
|
||||||
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
|
{{ m.measured_at|local_dt }}
|
||||||
</td>
|
</td>
|
||||||
<td style="color: var(--text-secondary);" class="text-sm">
|
<td style="color: var(--text-secondary);" class="text-sm">
|
||||||
{{ m.task_info.title if m.task_info else '-' }}
|
{{ m.task_info.title if m.task_info else '-' }}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ _("Monitor stazioni") }} — TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="tmf-page" x-data="stationMonitor()" x-init="init()">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _("Monitor stazioni") }}</h1>
|
||||||
|
<p class="text-sm text-steel mt-1">{{ _("Produzioni aperte, aggiornate ogni 10 secondi") }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-steel" x-text="updatedLabel"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="error" class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm" x-text="error"></div>
|
||||||
|
|
||||||
|
<div x-show="loaded && runs.length === 0 && !error"
|
||||||
|
class="p-8 text-center text-steel bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)]">
|
||||||
|
{{ _("Nessuna stazione in produzione") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
<template x-for="r in runs" :key="r.id">
|
||||||
|
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] border-l-8 shadow-sm p-4"
|
||||||
|
:class="borderClass(r)">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-bold text-[var(--text-primary)]" x-text="r.station_code + ' — ' + r.station_name"></div>
|
||||||
|
<div class="text-sm text-steel" x-text="r.recipe_code + ' ' + r.recipe_name + ' (v' + r.version_number + ')'"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-semibold px-2 py-1 rounded"
|
||||||
|
:class="r.overdue ? 'bg-red-600 text-white animate-pulse' : 'bg-gray-200 text-gray-700'"
|
||||||
|
x-text="statusLabel(r)"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-sm">
|
||||||
|
<dt class="text-steel">{{ _("Operatore") }}</dt><dd x-text="r.operator"></dd>
|
||||||
|
<dt class="text-steel">{{ _("Avviata alle") }}</dt><dd x-text="startedAt(r)"></dd>
|
||||||
|
<dt class="text-steel">{{ _("Durata") }}</dt><dd x-text="fmt(r.duration_seconds)"></dd>
|
||||||
|
<dt class="text-steel">{{ _("Cicli") }}</dt><dd x-text="r.cycle_count"></dd>
|
||||||
|
<dt class="text-steel">{{ _("Prossima misura") }}</dt>
|
||||||
|
<dd :class="r.overdue ? 'text-red-600 font-bold' : ''" x-text="timerLabel(r)"></dd>
|
||||||
|
<dt class="text-steel">{{ _("Ultimo evento") }}</dt>
|
||||||
|
<dd x-text="r.last_event ? r.last_event.event_type : '-'"></dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="mt-3 flex gap-2 text-xs font-semibold">
|
||||||
|
<span class="px-2 py-1 rounded bg-green-100 text-green-800" x-text="'OK ' + r.pass_count"></span>
|
||||||
|
<span class="px-2 py-1 rounded bg-yellow-100 text-yellow-800" x-text="'{{ _("Avviso") }} ' + r.warning_count"></span>
|
||||||
|
<span class="px-2 py-1 rounded bg-red-100 text-red-800" x-text="'NOK ' + r.fail_count"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="mt-3 space-y-1 text-sm">
|
||||||
|
<template x-for="s in r.subtasks" :key="s.subtask_id">
|
||||||
|
<li class="flex justify-between gap-2">
|
||||||
|
<span class="truncate" x-text="s.description"></span>
|
||||||
|
<span class="font-mono whitespace-nowrap"
|
||||||
|
:class="s.in_tolerance ? 'text-green-700' : 'text-red-700 font-bold'"
|
||||||
|
x-text="s.mean.toFixed(3) + ' ' + s.unit"></span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function stationMonitor() {
|
||||||
|
return {
|
||||||
|
runs: [], loaded: false, error: '', updatedLabel: '',
|
||||||
|
init() { this.load(); setInterval(() => this.load(), 10000); },
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/monitor/api/runs');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || data.error) { this.error = data.detail || {{ _("Errore di caricamento")|tojson }}; return; }
|
||||||
|
this.runs = data; this.error = ''; this.loaded = true;
|
||||||
|
this.updatedLabel = new Date().toLocaleTimeString();
|
||||||
|
} catch (e) { this.error = {{ _("Errore di rete")|tojson }}; }
|
||||||
|
},
|
||||||
|
// Green in tolerance, yellow with warnings, red with fails or a mean out of tolerance.
|
||||||
|
borderClass(r) {
|
||||||
|
if (r.fail_count > 0 || r.subtasks.some(s => !s.in_tolerance)) return 'border-l-red-600';
|
||||||
|
if (r.warning_count > 0) return 'border-l-yellow-500';
|
||||||
|
return 'border-l-green-600';
|
||||||
|
},
|
||||||
|
statusLabel(r) {
|
||||||
|
if (r.overdue) return {{ _("IN RITARDO")|tojson }};
|
||||||
|
return r.status === 'paused' ? {{ _("In pausa")|tojson }} : {{ _("In corso")|tojson }};
|
||||||
|
},
|
||||||
|
startedAt(r) { return new Date(r.started_at + 'Z').toLocaleTimeString(); },
|
||||||
|
timerLabel(r) {
|
||||||
|
if (r.seconds_to_next_measurement === null) return '-';
|
||||||
|
const s = r.seconds_to_next_measurement;
|
||||||
|
return (s < 0 ? '-' : '') + this.fmt(Math.abs(s));
|
||||||
|
},
|
||||||
|
fmt(sec) {
|
||||||
|
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
||||||
|
return (h ? h + 'h ' : '') + m + 'm ' + String(s).padStart(2, '0') + 's';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -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,46 @@
|
|||||||
|
"""Live station monitor page and JSON proxy (#57)."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_api():
|
||||||
|
with patch("blueprints.monitor.api_client") as m:
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, roles, is_admin=False):
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["api_key"] = "k"
|
||||||
|
sess["user"] = {"id": 2, "username": "u", "display_name": "U", "roles": roles,
|
||||||
|
"is_admin": is_admin, "language_pref": "en", "theme_pref": "light",
|
||||||
|
"active": True}
|
||||||
|
sess["language"] = "en"
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_ok_for_supervisor(client, mock_api):
|
||||||
|
_login(client, ["Supervisor"])
|
||||||
|
resp = client.get("/monitor")
|
||||||
|
assert resp.status_code == 200 and b"stationMonitor" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_forbidden_for_operator(client, mock_api):
|
||||||
|
_login(client, ["MeasurementTec"])
|
||||||
|
assert client.get("/monitor").status_code == 403
|
||||||
|
assert client.get("/monitor/api/runs").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_returns_backend_json(client, mock_api):
|
||||||
|
_login(client, ["Supervisor"])
|
||||||
|
mock_api.get.return_value = [{"id": 1, "station_code": "ST-1"}]
|
||||||
|
resp = client.get("/monitor/api/runs")
|
||||||
|
assert resp.status_code == 200 and resp.get_json()[0]["station_code"] == "ST-1"
|
||||||
|
mock_api.get.assert_called_once_with("/api/production-runs/open")
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_passes_backend_error(client, mock_api):
|
||||||
|
_login(client, [], is_admin=True)
|
||||||
|
mock_api.get.return_value = {"error": True, "status_code": 403, "detail": "no"}
|
||||||
|
resp = client.get("/monitor/api/runs")
|
||||||
|
assert resp.status_code == 403 and resp.get_json()["error"] is True
|
||||||
Reference in New Issue
Block a user