Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 787575807d |
@@ -14,7 +14,6 @@ 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,7 +14,6 @@ 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,8 +9,7 @@ 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
|
from src.backend.services.auth_service import hash_api_key, utcnow
|
||||||
from src.backend.utils.tempo import utcnow
|
|
||||||
|
|
||||||
_RENEW_INTERVAL = timedelta(minutes=5)
|
_RENEW_INTERVAL = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
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
|
||||||
@@ -234,8 +232,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: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = 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),
|
||||||
@@ -292,8 +290,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: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = 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),
|
||||||
@@ -374,7 +372,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,
|
||||||
iso_utc(m.measured_at),
|
m.measured_at.isoformat(),
|
||||||
m.synced_to_csv,
|
m.synced_to_csv,
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -384,6 +382,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_{to_local(utcnow()).strftime('%Y%m%d_%H%M%S')}.csv"
|
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Reports router — PDF report generation endpoints for Metrologist."""
|
"""Reports router — PDF report generation endpoints for Metrologist."""
|
||||||
from src.backend.models.api.tipi import UtcNaiveInput
|
from datetime import datetime
|
||||||
|
|
||||||
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: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = 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: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = 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,7 +6,6 @@ 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
|
||||||
@@ -556,7 +555,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 = utcnow()
|
base_date = datetime.now()
|
||||||
|
|
||||||
all_subtasks = subtasks_1 + subtasks_2
|
all_subtasks = subtasks_1 + subtasks_2
|
||||||
measurements_created = 0
|
measurements_created = 0
|
||||||
@@ -604,8 +603,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": iso_utc(u.created_at) or None,
|
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||||
"last_login": iso_utc(u.last_login) or None,
|
"last_login": u.last_login.isoformat() if u.last_login else None,
|
||||||
}
|
}
|
||||||
for u in users
|
for u in users
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""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
|
||||||
@@ -101,8 +100,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: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = 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),
|
||||||
@@ -123,8 +122,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: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = 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),
|
||||||
@@ -146,8 +145,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: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = 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),
|
||||||
@@ -173,8 +172,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: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = 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,9 +28,6 @@ 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 src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
from datetime import datetime
|
||||||
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[UtcDateTime] = None
|
authorised_at: Optional[datetime] = None
|
||||||
measured_at: UtcDateTime
|
measured_at: datetime
|
||||||
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: UtcDateTime
|
measured_at: datetime
|
||||||
|
|
||||||
|
|
||||||
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: UtcNaiveInput = None
|
date_from: Optional[datetime] = None
|
||||||
date_to: UtcNaiveInput = None
|
date_to: Optional[datetime] = 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)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for production runs and their events."""
|
"""Pydantic schemas for production runs and their events."""
|
||||||
from src.backend.models.api.tipi import UtcDateTime
|
from datetime import datetime
|
||||||
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: UtcDateTime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
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[UtcDateTime]
|
next_measurement_at: Optional[datetime]
|
||||||
cycle_count: int
|
cycle_count: int
|
||||||
started_at: UtcDateTime
|
started_at: datetime
|
||||||
paused_at: Optional[UtcDateTime]
|
paused_at: Optional[datetime]
|
||||||
closed_at: Optional[UtcDateTime]
|
closed_at: Optional[datetime]
|
||||||
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[UtcDateTime] = None
|
server_time: Optional[datetime] = 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 src.backend.models.api.tipi import UtcDateTime
|
from datetime import datetime
|
||||||
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: UtcDateTime
|
created_at: datetime
|
||||||
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: UtcDateTime
|
created_at: datetime
|
||||||
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 src.backend.models.api.tipi import UtcDateTime
|
from datetime import datetime
|
||||||
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: UtcDateTime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
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: UtcDateTime
|
assigned_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class RecipeSummary(BaseModel):
|
class RecipeSummary(BaseModel):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for SPC statistics responses."""
|
"""Pydantic schemas for SPC statistics responses."""
|
||||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
from datetime import datetime
|
||||||
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: UtcNaiveInput = None
|
date_from: Optional[datetime] = None
|
||||||
date_to: UtcNaiveInput = None
|
date_to: Optional[datetime] = 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[UtcDateTime]
|
timestamps: list[datetime]
|
||||||
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[UtcDateTime]
|
dates: list[datetime]
|
||||||
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]
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
"""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 src.backend.models.api.tipi import UtcDateTime
|
from datetime import datetime
|
||||||
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: UtcDateTime
|
created_at: datetime
|
||||||
last_login: Optional[UtcDateTime] = None
|
last_login: Optional[datetime] = 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 src.backend.models.api.tipi import UtcDateTime
|
from datetime import datetime
|
||||||
|
|
||||||
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: UtcDateTime
|
acquired_at: datetime
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""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
|
from datetime import datetime, timedelta, timezone
|
||||||
from src.backend.utils.tempo import utcnow
|
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -79,6 +78,11 @@ 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,7 +1,6 @@
|
|||||||
"""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
|
||||||
|
|
||||||
@@ -344,7 +343,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 = utcnow()
|
measurement.authorised_at = datetime.now()
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(measurement)
|
await db.refresh(measurement)
|
||||||
return measurement
|
return measurement
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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
|
||||||
@@ -170,9 +169,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,
|
||||||
iso_utc(measurement.measured_at),
|
measurement.measured_at.isoformat() if measurement.measured_at else "",
|
||||||
measurement.supervisor_id or "",
|
measurement.supervisor_id or "",
|
||||||
iso_utc(measurement.authorised_at),
|
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
|
||||||
])
|
])
|
||||||
|
|
||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
@@ -192,7 +191,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 = to_local(run.closed_at or utcnow()).strftime("%Y%m%d_%H%M%S")
|
stamp = (run.closed_at or datetime.now()).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,7 +14,6 @@ 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
|
||||||
@@ -35,10 +34,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 UTC, matching what the database writes for ``func.now()`` defaults,
|
Naive local time, 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 utcnow()
|
return datetime.now()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
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
|
||||||
@@ -33,11 +32,6 @@ _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,
|
||||||
@@ -329,9 +323,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: {_giorno_locale(date_from)}")
|
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
||||||
if date_to:
|
if date_to:
|
||||||
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
||||||
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:
|
||||||
@@ -348,7 +342,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=to_local(utcnow()),
|
generated_at=datetime.now(),
|
||||||
n_measurements=len(measurements),
|
n_measurements=len(measurements),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -401,7 +395,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": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
|
"measured_at": 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 "—",
|
||||||
@@ -420,9 +414,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: {_giorno_locale(date_from)}")
|
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
||||||
if date_to:
|
if date_to:
|
||||||
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
||||||
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:
|
||||||
@@ -436,7 +430,7 @@ async def generate_measurement_report(
|
|||||||
rows=rows,
|
rows=rows,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
filters_desc=filters_desc,
|
filters_desc=filters_desc,
|
||||||
generated_at=to_local(utcnow()),
|
generated_at=datetime.now(),
|
||||||
n_measurements=len(measurements),
|
n_measurements=len(measurements),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
"""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"
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""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))
|
|
||||||
@@ -59,45 +59,6 @@ def _post_login_landing():
|
|||||||
return url_for("auth.profile")
|
return url_for("auth.profile")
|
||||||
|
|
||||||
|
|
||||||
def _complete_login(response, fallback_name):
|
|
||||||
"""Dopo una risposta di login riuscita: sessione, preferenze, impostazioni (#59).
|
|
||||||
|
|
||||||
Comune al login con password e al login con PIN, cosi' i due restano identici.
|
|
||||||
Ritorna l'utente salvato in sessione.
|
|
||||||
"""
|
|
||||||
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
|
|
||||||
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
|
|
||||||
# limite configurato non ha mai effetto (#33).
|
|
||||||
session.permanent = True
|
|
||||||
session["api_key"] = response["api_key"]
|
|
||||||
session["user"] = response["user"]
|
|
||||||
session["user_id"] = response["user"]["id"]
|
|
||||||
|
|
||||||
# Applica preferenze utente
|
|
||||||
user = response["user"]
|
|
||||||
if user.get("language_pref"):
|
|
||||||
session["language"] = user["language_pref"]
|
|
||||||
if user.get("theme_pref"):
|
|
||||||
session["theme"] = user["theme_pref"]
|
|
||||||
|
|
||||||
# Carica impostazioni di sistema
|
|
||||||
settings_resp = api_client.get("/api/settings")
|
|
||||||
if not settings_resp.get("error"):
|
|
||||||
logo_path = settings_resp.get("company_logo_path")
|
|
||||||
if logo_path:
|
|
||||||
session["company_logo"] = logo_path
|
|
||||||
auto_logout = settings_resp.get("auto_logout_minutes")
|
|
||||||
if auto_logout:
|
|
||||||
try:
|
|
||||||
session["auto_logout_minutes"] = int(auto_logout)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", fallback_name)), "success")
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
"""Login page and form handler."""
|
"""Login page and form handler."""
|
||||||
@@ -120,7 +81,35 @@ def login():
|
|||||||
flash(response.get("detail", _("Credenziali non valide")), "error")
|
flash(response.get("detail", _("Credenziali non valide")), "error")
|
||||||
return render_template("auth/login.html")
|
return render_template("auth/login.html")
|
||||||
|
|
||||||
user = _complete_login(response, username)
|
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
|
||||||
|
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
|
||||||
|
# limite configurato non ha mai effetto (#33).
|
||||||
|
session.permanent = True
|
||||||
|
session["api_key"] = response["api_key"]
|
||||||
|
session["user"] = response["user"]
|
||||||
|
session["user_id"] = response["user"]["id"]
|
||||||
|
|
||||||
|
# Applica preferenze utente
|
||||||
|
user = response["user"]
|
||||||
|
if user.get("language_pref"):
|
||||||
|
session["language"] = user["language_pref"]
|
||||||
|
if user.get("theme_pref"):
|
||||||
|
session["theme"] = user["theme_pref"]
|
||||||
|
|
||||||
|
# Carica impostazioni di sistema
|
||||||
|
settings_resp = api_client.get("/api/settings")
|
||||||
|
if not settings_resp.get("error"):
|
||||||
|
logo_path = settings_resp.get("company_logo_path")
|
||||||
|
if logo_path:
|
||||||
|
session["company_logo"] = logo_path
|
||||||
|
auto_logout = settings_resp.get("auto_logout_minutes")
|
||||||
|
if auto_logout:
|
||||||
|
try:
|
||||||
|
session["auto_logout_minutes"] = int(auto_logout)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
||||||
|
|
||||||
# Redirect dopo login (in base al ruolo)
|
# Redirect dopo login (in base al ruolo)
|
||||||
return redirect(_post_login_landing())
|
return redirect(_post_login_landing())
|
||||||
@@ -132,55 +121,6 @@ def login():
|
|||||||
return render_template("auth/login.html")
|
return render_template("auth/login.html")
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/pin-users", methods=["GET"])
|
|
||||||
def pin_users():
|
|
||||||
"""Elenco nomi per la vista Operatore del login (#59): solo id e nome.
|
|
||||||
|
|
||||||
Pubblica come il login; se il backend non risponde o non ha il PIN, lista
|
|
||||||
vuota e la pagina apre il form password.
|
|
||||||
"""
|
|
||||||
if session.get("api_key"):
|
|
||||||
return jsonify([])
|
|
||||||
try:
|
|
||||||
resp = api_client.get("/api/auth/pin-users")
|
|
||||||
except Exception:
|
|
||||||
return jsonify([])
|
|
||||||
if not isinstance(resp, list):
|
|
||||||
return jsonify([])
|
|
||||||
return jsonify([
|
|
||||||
{"id": u.get("id"), "display_name": u.get("display_name", "")}
|
|
||||||
for u in resp if isinstance(u, dict)
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/login-pin", methods=["POST"])
|
|
||||||
def login_pin():
|
|
||||||
"""Login con nome + PIN (#59). JSON in/out; CSRF come il login password
|
|
||||||
(header X-CSRFToken, gestito da CSRFProtect)."""
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
user_id = data.get("user_id")
|
|
||||||
pin = str(data.get("pin", ""))
|
|
||||||
if not isinstance(user_id, int) or not (len(pin) == 4 and pin.isdigit()):
|
|
||||||
return jsonify({"error": True, "status_code": 422, "detail": _("Il PIN deve essere di 4 cifre")}), 422
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = api_client.post("/api/auth/login-pin", data={"user_id": user_id, "pin": pin})
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": True, "status_code": 502,
|
|
||||||
"detail": _("Errore di connessione al server: %(error)s", error=str(e))}), 502
|
|
||||||
|
|
||||||
if response.get("error"):
|
|
||||||
status = response.get("status_code") or 401
|
|
||||||
if status == 401:
|
|
||||||
detail = _("PIN errato")
|
|
||||||
else:
|
|
||||||
detail = response.get("detail") or _("Credenziali non valide")
|
|
||||||
return jsonify({"error": True, "status_code": status, "detail": detail}), status
|
|
||||||
|
|
||||||
_complete_login(response, "") # include il flash di benvenuto
|
|
||||||
return jsonify({"ok": True, "redirect": _post_login_landing()})
|
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/logout", methods=["POST"])
|
@auth_bp.route("/logout", methods=["POST"])
|
||||||
def logout():
|
def logout():
|
||||||
"""Clear session and redirect to login."""
|
"""Clear session and redirect to login."""
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
/* Login Operatore: nome + PIN a 4 cifre (#59).
|
|
||||||
* Componente Alpine globale `pinLogin()`, usato da templates/auth/login.html.
|
|
||||||
* Contratto: GET /auth/pin-users -> [{id, display_name}];
|
|
||||||
* POST /auth/login-pin {user_id, pin} -> {ok, redirect} | {error, detail}. */
|
|
||||||
function pinLogin() {
|
|
||||||
return {
|
|
||||||
mode: 'pin', // vista Operatore di default; se l'elenco e' vuoto/fallisce passa a 'password'
|
|
||||||
users: [],
|
|
||||||
selected: null,
|
|
||||||
pin: '',
|
|
||||||
error: '',
|
|
||||||
busy: false,
|
|
||||||
|
|
||||||
init: function () {
|
|
||||||
var self = this;
|
|
||||||
fetch(window.TMF_PIN.usersUrl, { headers: { 'Accept': 'application/json' } })
|
|
||||||
.then(function (r) { return r.ok ? r.json() : []; })
|
|
||||||
.then(function (list) {
|
|
||||||
self.users = Array.isArray(list) ? list : [];
|
|
||||||
if (!self.users.length) self.mode = 'password'; // elenco vuoto: form password
|
|
||||||
})
|
|
||||||
.catch(function () { self.users = []; self.mode = 'password'; });
|
|
||||||
},
|
|
||||||
select: function (u) { this.selected = u; this.pin = ''; this.error = ''; },
|
|
||||||
back: function () { this.selected = null; this.pin = ''; this.error = ''; },
|
|
||||||
del: function () { this.pin = this.pin.slice(0, -1); },
|
|
||||||
press: function (d) {
|
|
||||||
if (this.busy || this.pin.length >= 4) return;
|
|
||||||
this.pin += d;
|
|
||||||
this.error = '';
|
|
||||||
if (this.pin.length === 4) this.submit(); // invio automatico alla quarta cifra
|
|
||||||
},
|
|
||||||
submit: function () {
|
|
||||||
var self = this;
|
|
||||||
var csrf = document.querySelector('meta[name=csrf-token]');
|
|
||||||
self.busy = true;
|
|
||||||
fetch(window.TMF_PIN.loginUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-CSRFToken': (csrf && csrf.content) || ''
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ user_id: self.selected.id, pin: self.pin })
|
|
||||||
})
|
|
||||||
.then(function (r) { return r.json().catch(function () { return {}; }); })
|
|
||||||
.then(function (d) {
|
|
||||||
if (d.ok && d.redirect) { window.location.href = d.redirect; return; }
|
|
||||||
self.error = d.detail || window.TMF_PIN.msgNet; // 401 PIN errato, 423 blocco con ora
|
|
||||||
self.pin = '';
|
|
||||||
self.busy = false;
|
|
||||||
})
|
|
||||||
.catch(function () {
|
|
||||||
self.error = window.TMF_PIN.msgNet;
|
|
||||||
self.pin = '';
|
|
||||||
self.busy = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -26,50 +26,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-data="pinLogin()" x-init="init()">
|
<!-- Login Form -->
|
||||||
<!-- Vista Operatore: nome + PIN (#59) -->
|
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
||||||
<div x-show="mode === 'pin'" x-cloak>
|
|
||||||
<!-- Elenco nomi -->
|
|
||||||
<div x-show="!selected">
|
|
||||||
<p class="text-sm text-center text-[var(--text-secondary)] mb-4">{{ _('Chi sei?') }}</p>
|
|
||||||
<div class="grid grid-cols-2 gap-3" id="pin-user-list">
|
|
||||||
<template x-for="u in users" :key="u.id">
|
|
||||||
<button type="button" @click="select(u)"
|
|
||||||
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Tastierino -->
|
|
||||||
<div x-show="selected" id="pin-pad">
|
|
||||||
<p class="text-center font-semibold mb-1" x-text="selected && selected.display_name"></p>
|
|
||||||
<p class="text-sm text-center text-[var(--text-secondary)] mb-3">{{ _('Inserisci il PIN') }}</p>
|
|
||||||
<div class="flex justify-center gap-3 mb-3" aria-hidden="true">
|
|
||||||
<template x-for="i in 4" :key="i">
|
|
||||||
<span class="h-4 w-4 rounded-full border-2 border-[var(--border-color)]"
|
|
||||||
:class="pin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-center text-red-600 min-h-[1.25rem] mb-2" role="alert" x-text="error"></p>
|
|
||||||
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
|
|
||||||
<template x-for="d in ['1','2','3','4','5','6','7','8','9']" :key="d">
|
|
||||||
<button type="button" @click="press(d)" :disabled="busy"
|
|
||||||
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
|
|
||||||
</template>
|
|
||||||
<button type="button" @click="back()" :disabled="busy"
|
|
||||||
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
|
|
||||||
<button type="button" @click="press('0')" :disabled="busy"
|
|
||||||
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
|
|
||||||
<button type="button" @click="del()" :disabled="busy"
|
|
||||||
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-6 text-center">
|
|
||||||
<a href="#" @click.prevent="mode = 'password'" class="text-sm text-primary-600 hover:underline">{{ _('Accedi con password') }}</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login Form (password: admin e chi non ha PIN) -->
|
|
||||||
<form x-show="mode === 'password'" method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<!-- Username -->
|
<!-- Username -->
|
||||||
<div>
|
<div>
|
||||||
@@ -140,11 +98,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-4 text-center" x-show="mode === 'password' && users.length" x-cloak>
|
|
||||||
<a href="#" @click.prevent="mode = 'pin'" class="text-sm text-primary-600 hover:underline">{{ _('Accedi con PIN') }}</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Help Text -->
|
<!-- Help Text -->
|
||||||
<div class="mt-6 text-center">
|
<div class="mt-6 text-center">
|
||||||
<p class="text-xs text-[var(--text-muted)]">
|
<p class="text-xs text-[var(--text-muted)]">
|
||||||
@@ -162,15 +115,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
/* Configurazione e testi per pin-pad.js (#59) */
|
|
||||||
window.TMF_PIN = {
|
|
||||||
usersUrl: {{ url_for('auth.pin_users')|tojson }},
|
|
||||||
loginUrl: {{ url_for('auth.login_pin')|tojson }},
|
|
||||||
msgNet: {{ _('Errore di connessione al server')|tojson }}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<script src="{{ url_for('static', filename='js/pin-pad.js') }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -136,67 +136,3 @@ class TestLanguageSwitch:
|
|||||||
with client.session_transaction() as sess:
|
with client.session_transaction() as sess:
|
||||||
# Invalid language should NOT be stored
|
# Invalid language should NOT be stored
|
||||||
assert sess.get("language") != "xx"
|
assert sess.get("language") != "xx"
|
||||||
|
|
||||||
|
|
||||||
class TestPinLogin:
|
|
||||||
"""Login Operatore con nome + PIN (#59)."""
|
|
||||||
|
|
||||||
USER = {
|
|
||||||
"id": 7, "username": "mario", "display_name": "Mario Rossi",
|
|
||||||
"roles": ["MeasurementTec"], "is_admin": False,
|
|
||||||
"language_pref": "it", "theme_pref": "light", "active": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_login_page_has_pin_view_and_password_link(self, client):
|
|
||||||
resp = client.get("/auth/login")
|
|
||||||
assert b"pin-pad.js" in resp.data
|
|
||||||
assert b"pin-pad" in resp.data # tastierino presente nel markup
|
|
||||||
assert b'name="password"' in resp.data # form password ancora presente
|
|
||||||
|
|
||||||
def test_pin_users_listed(self, client, mock_api_client):
|
|
||||||
mock_api_client.get.return_value = [
|
|
||||||
{"id": 7, "display_name": "Mario Rossi", "secret": "x"},
|
|
||||||
]
|
|
||||||
resp = client.get("/auth/pin-users")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.get_json() == [{"id": 7, "display_name": "Mario Rossi"}]
|
|
||||||
|
|
||||||
def test_pin_users_empty_on_error(self, client, mock_api_client):
|
|
||||||
mock_api_client.get.return_value = {"error": True, "detail": "boom"}
|
|
||||||
assert client.get("/auth/pin-users").get_json() == []
|
|
||||||
|
|
||||||
def test_pin_users_empty_list_falls_back(self, client, mock_api_client):
|
|
||||||
mock_api_client.get.return_value = []
|
|
||||||
assert client.get("/auth/pin-users").get_json() == []
|
|
||||||
|
|
||||||
def test_login_pin_success_sets_session(self, client, mock_api_client):
|
|
||||||
mock_api_client.post.return_value = {"api_key": "pin-key", "user": self.USER}
|
|
||||||
mock_api_client.get.return_value = {}
|
|
||||||
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "1234"})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.get_json()["ok"] is True
|
|
||||||
assert resp.get_json()["redirect"]
|
|
||||||
mock_api_client.post.assert_called_once_with(
|
|
||||||
"/api/auth/login-pin", data={"user_id": 7, "pin": "1234"})
|
|
||||||
with client.session_transaction() as sess:
|
|
||||||
assert sess["api_key"] == "pin-key"
|
|
||||||
assert sess["user_id"] == 7
|
|
||||||
assert sess.permanent is True
|
|
||||||
|
|
||||||
def test_login_pin_wrong_pin_401(self, client, mock_api_client):
|
|
||||||
mock_api_client.post.return_value = {"error": True, "status_code": 401, "detail": "x"}
|
|
||||||
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
|
|
||||||
assert resp.status_code == 401
|
|
||||||
assert resp.get_json()["detail"] == "PIN errato"
|
|
||||||
|
|
||||||
def test_login_pin_locked_423_shows_detail(self, client, mock_api_client):
|
|
||||||
mock_api_client.post.return_value = {
|
|
||||||
"error": True, "status_code": 423, "detail": "Utente bloccato fino alle 10:15"}
|
|
||||||
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
|
|
||||||
assert resp.status_code == 423
|
|
||||||
assert "10:15" in resp.get_json()["detail"]
|
|
||||||
|
|
||||||
def test_login_pin_invalid_pin_422(self, client, mock_api_client):
|
|
||||||
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "12"})
|
|
||||||
assert resp.status_code == 422
|
|
||||||
mock_api_client.post.assert_not_called()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user