Compare commits

..

4 Commits

33 changed files with 839 additions and 315 deletions
+1
View File
@@ -14,6 +14,7 @@ services:
--authentication-policy=mysql_native_password --authentication-policy=mysql_native_password
--character-set-server=utf8mb4 --character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci --collation-server=utf8mb4_unicode_ci
--default-time-zone=+00:00
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s interval: 10s
+1
View File
@@ -14,6 +14,7 @@ services:
--authentication-policy=mysql_native_password --authentication-policy=mysql_native_password
--character-set-server=utf8mb4 --character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci --collation-server=utf8mb4_unicode_ci
--default-time-zone=+00:00
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s interval: 10s
+2 -1
View File
@@ -9,7 +9,8 @@ from src.backend.database import get_db
from src.backend.config import settings from src.backend.config import settings
from src.backend.models.orm.api_key import ApiKey from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.services.auth_service import hash_api_key, utcnow from src.backend.services.auth_service import hash_api_key
from src.backend.utils.tempo import utcnow
_RENEW_INTERVAL = timedelta(minutes=5) _RENEW_INTERVAL = timedelta(minutes=5)
+8 -6
View File
@@ -2,6 +2,8 @@
import csv import csv
import io import io
from datetime import datetime from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from src.backend.utils.tempo import as_utc_naive, iso_utc, to_local, utcnow
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -232,8 +234,8 @@ async def get_measurements(
measured_by: int | None = Query(None), measured_by: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
production_run_id: int | None = Query(None, gt=0), production_run_id: int | None = Query(None, gt=0),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@@ -290,8 +292,8 @@ async def export_measurements_csv(
measured_by: int | None = Query(None), measured_by: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -372,7 +374,7 @@ async def export_measurements_csv(
neutralise_csv_cell(m.lot_number or ""), neutralise_csv_cell(m.lot_number or ""),
neutralise_csv_cell(m.serial_number or ""), neutralise_csv_cell(m.serial_number or ""),
m.input_method, m.input_method,
m.measured_at.isoformat(), iso_utc(m.measured_at),
m.synced_to_csv, m.synced_to_csv,
]) ])
@@ -382,6 +384,6 @@ async def export_measurements_csv(
iter([output.getvalue()]), iter([output.getvalue()]),
media_type="text/csv", media_type="text/csv",
headers={ headers={
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" "Content-Disposition": f"attachment; filename=measurements_{to_local(utcnow()).strftime('%Y%m%d_%H%M%S')}.csv"
}, },
) )
+5 -5
View File
@@ -1,5 +1,5 @@
"""Reports router — PDF report generation endpoints for Metrologist.""" """Reports router — PDF report generation endpoints for Metrologist."""
from datetime import datetime from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response from fastapi.responses import Response
@@ -18,8 +18,8 @@ async def download_spc_report(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
@@ -52,8 +52,8 @@ async def download_measurement_report(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int | None = Query(None), subtask_id: int | None = Query(None),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
+43
View File
@@ -1,4 +1,7 @@
"""System settings router - read/update settings, upload company logo.""" """System settings router - read/update settings, upload company logo."""
import json
from decimal import Decimal, InvalidOperation
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +21,23 @@ _VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
_VALID_CSV_DECIMAL_SEPARATORS = {",", "."} _VALID_CSV_DECIMAL_SEPARATORS = {",", "."}
def _validate_string_list(key: str, value: str) -> None:
"""#49: array JSON di stringhe non vuote, max 50 voci (vuoto = nessuna lista)."""
try:
items = json.loads(value)
except ValueError:
items = None
if (
not isinstance(items, list)
or len(items) > 50
or not all(isinstance(i, str) and i.strip() for i in items)
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{key} deve essere vuoto o un array JSON di al massimo 50 stringhe non vuote",
)
def _validate_setting_value(key: str, value: str) -> None: def _validate_setting_value(key: str, value: str) -> None:
"""Raise 422 for a value that would break the code reading this key. """Raise 422 for a value that would break the code reading this key.
@@ -41,6 +61,29 @@ def _validate_setting_value(key: str, value: str) -> None:
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="auto_logout_minutes deve essere vuoto (disabilitato) o un intero fra 1 e 480", detail="auto_logout_minutes deve essere vuoto (disabilitato) o un intero fra 1 e 480",
) )
elif key == "default_measurement_interval_minutes":
# #47: vuoto = nessun default
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 1440)):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="default_measurement_interval_minutes deve essere vuoto o un intero fra 1 e 1440",
)
elif key in ("general_tolerance_plus", "general_tolerance_minus"):
# #48: decimale >= 0 con punto; vuoto = nessun default
if value != "":
try:
number = Decimal(value)
ok = number.is_finite() and number >= 0
except InvalidOperation:
ok = False
if not ok:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{key} deve essere vuoto o un numero decimale maggiore o uguale a 0 (punto decimale)",
)
elif key in ("list_measurement_types", "list_units"):
if value != "":
_validate_string_list(key, value)
elif key in ("company_name", "company_logo_path"): elif key in ("company_name", "company_logo_path"):
if not value.strip() or len(value) > 200: if not value.strip() or len(value) > 200:
raise HTTPException( raise HTTPException(
+4 -3
View File
@@ -6,6 +6,7 @@ NOT protected by API Key auth (standalone access).
import hmac import hmac
import random import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
from src.backend.utils.tempo import iso_utc, utcnow
from pathlib import Path from pathlib import Path
from alembic import command from alembic import command
@@ -555,7 +556,7 @@ async def seed_demo_data(body: PasswordBody):
tec_user = created_users[2] # tec1 tec_user = created_users[2] # tec1
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"] lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)] serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
base_date = datetime.now() base_date = utcnow()
all_subtasks = subtasks_1 + subtasks_2 all_subtasks = subtasks_1 + subtasks_2
measurements_created = 0 measurements_created = 0
@@ -603,8 +604,8 @@ async def list_users(body: PasswordBody):
"roles": u.roles or [], "roles": u.roles or [],
"is_admin": u.is_admin, "is_admin": u.is_admin,
"active": u.active, "active": u.active,
"created_at": u.created_at.isoformat() if u.created_at else None, "created_at": iso_utc(u.created_at) or None,
"last_login": u.last_login.isoformat() if u.last_login else None, "last_login": iso_utc(u.last_login) or None,
} }
for u in users for u in users
], ],
+9 -8
View File
@@ -1,5 +1,6 @@
"""Statistics router - SPC endpoints for Metrologist dashboard.""" """Statistics router - SPC endpoints for Metrologist dashboard."""
from datetime import datetime from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, select from sqlalchemy import and_, select
@@ -100,8 +101,8 @@ async def get_summary(
recipe_id: int = Query(...), recipe_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
subtask_id: int | None = Query(None), subtask_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
@@ -122,8 +123,8 @@ async def get_capability(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
@@ -145,8 +146,8 @@ async def get_control_chart(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
@@ -172,8 +173,8 @@ async def get_histogram(
recipe_id: int = Query(...), recipe_id: int = Query(...),
subtask_id: int = Query(...), subtask_id: int = Query(...),
version_id: int | None = Query(None), version_id: int | None = Query(None),
date_from: datetime | None = Query(None), date_from: UtcNaiveInput = Query(None),
date_to: datetime | None = Query(None), date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
+3
View File
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
db_user: str = "tmflow" db_user: str = "tmflow"
db_password: str = _DEFAULT_DB_PASSWORD db_password: str = _DEFAULT_DB_PASSWORD
# Presentation time zone (DB is UTC, #42)
display_timezone: str = "Europe/Rome"
# Server # Server
server_host: str = "0.0.0.0" server_host: str = "0.0.0.0"
server_port: int = 8000 server_port: int = 8000
+6 -6
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Measurement operations.""" """Pydantic schemas for Measurement operations."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -54,8 +54,8 @@ class MeasurementResponse(BaseModel):
# Who let this value stand, when it was out of tolerance. Null on a value in # Who let this value stand, when it was out of tolerance. Null on a value in
# tolerance - and on one that is not and is still waiting for an answer. # tolerance - and on one that is not and is still waiting for an answer.
supervisor_id: Optional[int] = None supervisor_id: Optional[int] = None
authorised_at: Optional[datetime] = None authorised_at: Optional[UtcDateTime] = None
measured_at: datetime measured_at: UtcDateTime
synced_to_csv: bool synced_to_csv: bool
@@ -97,7 +97,7 @@ class CycleMeasurementResponse(BaseModel):
subtask_id: int subtask_id: int
value: float value: float
pass_fail: str pass_fail: str
measured_at: datetime measured_at: UtcDateTime
class CurrentCycleResponse(BaseModel): class CurrentCycleResponse(BaseModel):
@@ -114,8 +114,8 @@ class MeasurementQuery(BaseModel):
measured_by: Optional[int] = None measured_by: Optional[int] = None
lot_number: Optional[str] = None lot_number: Optional[str] = None
serial_number: Optional[str] = None serial_number: Optional[str] = None
date_from: Optional[datetime] = None date_from: UtcNaiveInput = None
date_to: Optional[datetime] = None date_to: UtcNaiveInput = None
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$") pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
page: int = Field(1, ge=1) page: int = Field(1, ge=1)
per_page: int = Field(50, ge=1, le=500) per_page: int = Field(50, ge=1, le=500)
+7 -7
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for production runs and their events.""" """Pydantic schemas for production runs and their events."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from typing import Literal, Optional from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -21,7 +21,7 @@ class ProductionEventResponse(BaseModel):
supervisor_id: Optional[int] supervisor_id: Optional[int]
task_id: Optional[int] = None task_id: Optional[int] = None
note: Optional[str] note: Optional[str]
created_at: datetime created_at: UtcDateTime
class ProductionRunResponse(BaseModel): class ProductionRunResponse(BaseModel):
@@ -36,11 +36,11 @@ class ProductionRunResponse(BaseModel):
serial_number: Optional[str] serial_number: Optional[str]
status: str status: str
measurement_interval_minutes: Optional[int] measurement_interval_minutes: Optional[int]
next_measurement_at: Optional[datetime] next_measurement_at: Optional[UtcDateTime]
cycle_count: int cycle_count: int
started_at: datetime started_at: UtcDateTime
paused_at: Optional[datetime] paused_at: Optional[UtcDateTime]
closed_at: Optional[datetime] closed_at: Optional[UtcDateTime]
closed_by: Optional[int] closed_by: Optional[int]
statistics_path: Optional[str] = None statistics_path: Optional[str] = None
@@ -49,7 +49,7 @@ class ProductionRunResponse(BaseModel):
# overdue is a fact the operator must see, not deduce. # overdue is a fact the operator must see, not deduce.
seconds_to_next_measurement: Optional[int] = None seconds_to_next_measurement: Optional[int] = None
overdue: bool = False overdue: bool = False
server_time: Optional[datetime] = None server_time: Optional[UtcDateTime] = None
# The measurement tasks of this run's version, in execution order. The client # The measurement tasks of this run's version, in execution order. The client
# needs them to bring the operator back to the measurement when the interval # needs them to bring the operator back to the measurement when the interval
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Recipe and RecipeVersion operations.""" """Pydantic schemas for Recipe and RecipeVersion operations."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -51,7 +51,7 @@ class RecipeVersionResponse(BaseModel):
version_number: int version_number: int
is_current: bool is_current: bool
created_by: int created_by: int
created_at: datetime created_at: UtcDateTime
change_notes: Optional[str] = None change_notes: Optional[str] = None
tasks: list["TaskResponse"] = [] tasks: list["TaskResponse"] = []
@@ -72,7 +72,7 @@ class RecipeResponse(BaseModel):
requires_serial: bool = False requires_serial: bool = False
allow_manual_input: bool = False allow_manual_input: bool = False
created_by: int created_by: int
created_at: datetime created_at: UtcDateTime
active: bool active: bool
current_version: Optional[RecipeVersionResponse] = None current_version: Optional[RecipeVersionResponse] = None
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Station and StationRecipeAssignment.""" """Pydantic schemas for Station and StationRecipeAssignment."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -29,7 +29,7 @@ class StationResponse(BaseModel):
notes: Optional[str] notes: Optional[str]
active: bool active: bool
created_by: int created_by: int
created_at: datetime created_at: UtcDateTime
class StationRecipeAssignmentCreate(BaseModel): class StationRecipeAssignmentCreate(BaseModel):
@@ -42,7 +42,7 @@ class StationRecipeAssignmentResponse(BaseModel):
station_id: int station_id: int
recipe_id: int recipe_id: int
assigned_by: int assigned_by: int
assigned_at: datetime assigned_at: UtcDateTime
class RecipeSummary(BaseModel): class RecipeSummary(BaseModel):
+5 -5
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for SPC statistics responses.""" """Pydantic schemas for SPC statistics responses."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
recipe_id: int recipe_id: int
version_id: Optional[int] = None version_id: Optional[int] = None
subtask_id: Optional[int] = None subtask_id: Optional[int] = None
date_from: Optional[datetime] = None date_from: UtcNaiveInput = None
date_to: Optional[datetime] = None date_to: UtcNaiveInput = None
operator_id: Optional[int] = None operator_id: Optional[int] = None
lot_number: Optional[str] = None lot_number: Optional[str] = None
serial_number: Optional[str] = None serial_number: Optional[str] = None
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
class ControlChartData(BaseModel): class ControlChartData(BaseModel):
"""Data for X-bar/R control chart.""" """Data for X-bar/R control chart."""
values: list[float] values: list[float]
timestamps: list[datetime] timestamps: list[UtcDateTime]
mean: float mean: float
ucl: float # Upper Control Limit ucl: float # Upper Control Limit
lcl: float # Lower Control Limit lcl: float # Lower Control Limit
@@ -60,7 +60,7 @@ class CapabilityData(BaseModel):
class TrendData(BaseModel): class TrendData(BaseModel):
"""Temporal trend data for capability indices.""" """Temporal trend data for capability indices."""
dates: list[datetime] dates: list[UtcDateTime]
cpk_values: list[Optional[float]] cpk_values: list[Optional[float]]
ppk_values: list[Optional[float]] ppk_values: list[Optional[float]]
n_per_period: list[int] n_per_period: list[int]
+14
View File
@@ -0,0 +1,14 @@
"""Shared Pydantic types for datetimes (#42): UTC in the DB, explicit +00:00 on the wire."""
from datetime import datetime
from typing import Annotated, Optional
from pydantic import AfterValidator, PlainSerializer
from src.backend.utils.tempo import as_utc_naive, to_utc_aware
UtcDateTime = Annotated[
datetime, PlainSerializer(lambda v: to_utc_aware(v).isoformat(), return_type=str)
]
# Input: aware values are converted to UTC naive, naive is taken as UTC.
UtcNaiveInput = Annotated[Optional[datetime], AfterValidator(as_utc_naive)]
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for User operations.""" """Pydantic schemas for User operations."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -48,8 +48,8 @@ class UserResponse(BaseModel):
language_pref: str language_pref: str
theme_pref: str theme_pref: str
active: bool active: bool
created_at: datetime created_at: UtcDateTime
last_login: Optional[datetime] = None last_login: Optional[UtcDateTime] = None
class UserPasswordChange(BaseModel): class UserPasswordChange(BaseModel):
+2 -2
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for the vision execution response.""" """Pydantic schemas for the vision execution response."""
from datetime import datetime from src.backend.models.api.tipi import UtcDateTime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -28,4 +28,4 @@ class VisionReferenceImageResponse(BaseModel):
note: str | None = None note: str | None = None
station_id: int | None = None station_id: int | None = None
device_code: str | None = None device_code: str | None = None
acquired_at: datetime acquired_at: UtcDateTime
+2 -6
View File
@@ -1,7 +1,8 @@
"""Authentication service - password hashing, API key management.""" """Authentication service - password hashing, API key management."""
import hashlib import hashlib
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from src.backend.utils.tempo import utcnow
import bcrypt import bcrypt
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -78,11 +79,6 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
return supervisor return supervisor
def utcnow() -> datetime:
"""Naive UTC now (the whole DB moves to naive UTC, #42)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def hash_api_key(key: str) -> str: def hash_api_key(key: str) -> str:
"""SHA-256 hex digest of an API key; this is all the DB ever stores.""" """SHA-256 hex digest of an API key; this is all the DB ever stores."""
return hashlib.sha256(key.encode("utf-8")).hexdigest() return hashlib.sha256(key.encode("utf-8")).hexdigest()
+2 -1
View File
@@ -1,6 +1,7 @@
"""Measurement service - pass/fail calculation, data storage.""" """Measurement service - pass/fail calculation, data storage."""
import math import math
from datetime import datetime from datetime import datetime
from src.backend.utils.tempo import utcnow
from decimal import Decimal from decimal import Decimal
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -343,7 +344,7 @@ async def authorise_measurement(
) -> Measurement: ) -> Measurement:
"""Let an out-of-tolerance value stand, on the supervisor's name.""" """Let an out-of-tolerance value stand, on the supervisor's name."""
measurement.supervisor_id = supervisor.id measurement.supervisor_id = supervisor.id
measurement.authorised_at = datetime.now() measurement.authorised_at = utcnow()
await db.flush() await db.flush()
await db.refresh(measurement) await db.refresh(measurement)
return measurement return measurement
@@ -14,6 +14,7 @@ import csv
import io import io
import logging import logging
from datetime import datetime from datetime import datetime
from src.backend.utils.tempo import iso_utc, to_local, utcnow
from pathlib import Path from pathlib import Path
from sqlalchemy import select, update from sqlalchemy import select, update
@@ -169,9 +170,9 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
measurement.input_method, measurement.input_method,
"" if measurement.input_duration_ms is None else measurement.input_duration_ms, "" if measurement.input_duration_ms is None else measurement.input_duration_ms,
measurement.measured_by, measurement.measured_by,
measurement.measured_at.isoformat() if measurement.measured_at else "", iso_utc(measurement.measured_at),
measurement.supervisor_id or "", measurement.supervisor_id or "",
measurement.authorised_at.isoformat() if measurement.authorised_at else "", iso_utc(measurement.authorised_at),
]) ])
return output.getvalue() return output.getvalue()
@@ -191,7 +192,7 @@ async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | N
directory = Path(settings.upload_path) / EXPORT_SUBDIR directory = Path(settings.upload_path) / EXPORT_SUBDIR
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S") stamp = to_local(run.closed_at or utcnow()).strftime("%Y%m%d_%H%M%S")
filename = f"production_{run.id}_{stamp}.csv" filename = f"production_{run.id}_{stamp}.csv"
# A large run's file can take a moment to write: off the event loop, so # A large run's file can take a moment to write: off the event loop, so
# closing one run does not stall every other request being served. # closing one run does not stall every other request being served.
+3 -2
View File
@@ -14,6 +14,7 @@ the countdown would be off by the UTC offset. The number is the contract; the
timestamp is only there for the audit trail. timestamp is only there for the audit trail.
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
from src.backend.utils.tempo import utcnow
from typing import Optional from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -34,10 +35,10 @@ from src.backend.services import (
def _now() -> datetime: def _now() -> datetime:
"""Single source of time for runs. """Single source of time for runs.
Naive local time, matching what the database writes for ``func.now()`` defaults, Naive UTC, matching what the database writes for ``func.now()`` defaults,
so a run's started_at and its deadline are comparable. so a run's started_at and its deadline are comparable.
""" """
return datetime.now() return utcnow()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+13 -7
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import base64 import base64
from datetime import datetime from datetime import datetime
from src.backend.utils.tempo import to_local, utcnow
from pathlib import Path from pathlib import Path
import plotly.graph_objects as go import plotly.graph_objects as go
@@ -32,6 +33,11 @@ _jinja_env = Environment(
) )
def _giorno_locale(dt: datetime) -> str:
"""UTC naive filter bound -> local calendar day (YYYY-MM-DD)."""
return to_local(dt).strftime("%Y-%m-%d")
async def _query_measurements( async def _query_measurements(
db: AsyncSession, db: AsyncSession,
recipe_id: int, recipe_id: int,
@@ -323,9 +329,9 @@ async def generate_spc_report(
if version_id: if version_id:
filters_desc.append(f"Version: {version_id}") filters_desc.append(f"Version: {version_id}")
if date_from: if date_from:
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}") filters_desc.append(f"From: {_giorno_locale(date_from)}")
if date_to: if date_to:
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}") filters_desc.append(f"To: {_giorno_locale(date_to)}")
if lot_number: if lot_number:
filters_desc.append(f"Lot: {lot_number}") filters_desc.append(f"Lot: {lot_number}")
if serial_number: if serial_number:
@@ -342,7 +348,7 @@ async def generate_spc_report(
control_chart_svg=control_chart_svg, control_chart_svg=control_chart_svg,
histogram_svg=histogram_svg, histogram_svg=histogram_svg,
filters_desc=filters_desc, filters_desc=filters_desc,
generated_at=datetime.now(), generated_at=to_local(utcnow()),
n_measurements=len(measurements), n_measurements=len(measurements),
) )
@@ -395,7 +401,7 @@ async def generate_measurement_report(
"value": f"{float(m.value):.4f}", "value": f"{float(m.value):.4f}",
"unit": st_info.get("unit", "mm"), "unit": st_info.get("unit", "mm"),
"pass_fail": m.pass_fail, "pass_fail": m.pass_fail,
"measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"), "measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"), "operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
"lot_number": m.lot_number or "—", "lot_number": m.lot_number or "—",
"serial_number": m.serial_number or "—", "serial_number": m.serial_number or "—",
@@ -414,9 +420,9 @@ async def generate_measurement_report(
if version_id: if version_id:
filters_desc.append(f"Version: {version_id}") filters_desc.append(f"Version: {version_id}")
if date_from: if date_from:
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}") filters_desc.append(f"From: {_giorno_locale(date_from)}")
if date_to: if date_to:
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}") filters_desc.append(f"To: {_giorno_locale(date_to)}")
if lot_number: if lot_number:
filters_desc.append(f"Lot: {lot_number}") filters_desc.append(f"Lot: {lot_number}")
if serial_number: if serial_number:
@@ -430,7 +436,7 @@ async def generate_measurement_report(
rows=rows, rows=rows,
summary=summary, summary=summary,
filters_desc=filters_desc, filters_desc=filters_desc,
generated_at=datetime.now(), generated_at=to_local(utcnow()),
n_measurements=len(measurements), n_measurements=len(measurements),
) )
+44
View File
@@ -176,6 +176,50 @@ class TestUpdateSettingsValidation:
) )
assert resp.status_code == 200 assert resp.status_code == 200
# --- #47/#48/#49: chiavi Impostazioni di Tràfilo ---
@pytest.mark.parametrize("key,value", [
("default_measurement_interval_minutes", "1"),
("default_measurement_interval_minutes", "1440"),
("default_measurement_interval_minutes", ""),
("general_tolerance_plus", "0"),
("general_tolerance_plus", "0.05"),
("general_tolerance_minus", ""),
("list_measurement_types", '["Lunghezza", "Diametro"]'),
("list_units", "[]"),
("list_units", ""),
])
async def test_new_keys_valid_accepted(
self, client: AsyncClient, admin_user: User, key: str, value: str
):
resp = await client.put(
"/api/settings/", headers=auth_headers(admin_user), json={key: value}
)
assert resp.status_code == 200, resp.text
@pytest.mark.parametrize("key,value", [
("default_measurement_interval_minutes", "0"),
("default_measurement_interval_minutes", "1441"),
("default_measurement_interval_minutes", "abc"),
("general_tolerance_plus", "-0.1"),
("general_tolerance_plus", "0,05"),
("general_tolerance_minus", "NaN"),
("general_tolerance_minus", "abc"),
("list_measurement_types", "not json"),
("list_measurement_types", '{"a": 1}'),
("list_units", '["mm", ""]'),
("list_units", '["mm", 3]'),
("list_units", "[" + ",".join(f'"v{i}"' for i in range(51)) + "]"),
])
async def test_new_keys_invalid_rejected(
self, client: AsyncClient, admin_user: User, key: str, value: str
):
resp = await client.put(
"/api/settings/", headers=auth_headers(admin_user), json={key: value}
)
assert resp.status_code == 422, resp.text
assert key in resp.json()["detail"]
async def test_unknown_key_not_validated( async def test_unknown_key_not_validated(
self, client: AsyncClient, admin_user: User self, client: AsyncClient, admin_user: User
): ):
+113
View File
@@ -0,0 +1,113 @@
"""UTC in the DB, explicit +00:00 on the wire, local time only on presentation (#42)."""
from datetime import datetime, timezone
from unittest.mock import patch
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.user import User
from src.backend.services import report_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.utils.tempo import as_utc_naive, to_local, utcnow
def test_utcnow_naive_and_close_to_utc():
now = utcnow()
assert now.tzinfo is None
delta = datetime.now(timezone.utc).replace(tzinfo=None) - now
assert abs(delta.total_seconds()) < 5
def test_as_utc_naive():
parsed = datetime.fromisoformat("2026-10-06T10:00:00+02:00")
assert as_utc_naive(parsed) == datetime(2026, 10, 6, 8, 0)
assert as_utc_naive(parsed).tzinfo is None
naive = datetime(2026, 10, 6, 8, 0)
assert as_utc_naive(naive) is naive
assert as_utc_naive(None) is None
def test_to_local_is_rome():
assert to_local(datetime(2026, 10, 6, 8, 0)).strftime("%H:%M %z") == "10:00 +0200"
assert to_local(datetime(2026, 1, 6, 8, 0)).strftime("%H:%M %z") == "09:00 +0100"
async def _make_measurement(client, user, recipe_id) -> None:
tasks = (await client.get(f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user))).json()
await client.post(
"/api/measurements/",
headers=auth_headers(user),
json={
"subtask_id": tasks[0]["subtasks"][0]["id"],
"version_id": tasks[0]["version_id"],
"value": 10.0,
},
)
async def test_api_datetimes_carry_utc_offset_and_filter_equivalence(
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
await _make_measurement(client, measurement_tec_user, recipe.id)
resp = await client.get("/api/measurements/", headers=auth_headers(metrologist_user))
item = resp.json()["items"][0]
assert item["measured_at"].endswith("+00:00")
# Same instant written with +02:00 and as UTC: identical results.
future_utc = "2099-01-01T08:00:00+00:00"
future_rome = "2099-01-01T10:00:00+02:00"
past_utc = "2000-01-01T08:00:00+00:00"
past_rome = "2000-01-01T10:00:00+02:00"
for frm, to in ((past_utc, future_utc), (past_rome, future_rome)):
r = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
params={"date_from": frm, "date_to": to},
)
assert r.status_code == 200
assert r.json()["total"] == 1
# Boundary: measurement is "now" in UTC; a date_from 1h ahead in UTC,
# written as +02:00 (now+3h local wall clock), must exclude it.
ahead = (utcnow().replace(microsecond=0)).replace(tzinfo=timezone.utc)
from datetime import timedelta
ahead_utc = (ahead + timedelta(hours=1)).isoformat()
ahead_rome = (ahead + timedelta(hours=1)).astimezone(
timezone(timedelta(hours=2))
).isoformat()
totals = []
for frm in (ahead_utc, ahead_rome):
r = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
params={"date_from": frm},
)
totals.append(r.json()["total"])
assert totals == [0, 0]
async def test_report_generated_at_is_local_time(
db_session: AsyncSession, metrologist_user: User
):
recipe = await create_test_recipe(db_session, metrologist_user.id)
captured: dict[str, str] = {}
def fake_render(html: str) -> bytes:
captured["html"] = html
return b"%PDF"
with patch.object(report_service, "utcnow", return_value=datetime(2026, 10, 6, 8, 0)), \
patch.object(report_service, "_render_pdf", side_effect=fake_render):
await report_service.generate_measurement_report(db_session, recipe.id)
assert "Generated 2026-10-06 10:00" in captured["html"]
def test_report_filter_day_is_local():
"""UTC naive 22:00 of Oct 5 is already Oct 6 in Europe/Rome (#42)."""
assert report_service._giorno_locale(datetime(2026, 10, 5, 22, 0, 0)) == "2026-10-06"
View File
+34
View File
@@ -0,0 +1,34 @@
"""Time helpers: the DB holds naive UTC, presentation converts to display_timezone (#42)."""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from src.backend.config import settings
def utcnow() -> datetime:
"""Naive UTC now (the whole DB moves to naive UTC, #42)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def as_utc_naive(dt: datetime | None) -> datetime | None:
"""Aware -> UTC naive; naive is taken as UTC already; None stays None."""
if dt is None or dt.tzinfo is None:
return dt
return dt.astimezone(timezone.utc).replace(tzinfo=None)
def to_utc_aware(dt: datetime) -> datetime:
"""Naive (UTC) or aware datetime -> aware UTC."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def iso_utc(dt: datetime | None) -> str:
"""ISO 8601 with explicit +00:00; "" for None (CSV cells)."""
return to_utc_aware(dt).isoformat() if dt else ""
def to_local(dt: datetime) -> datetime:
"""Naive UTC (or aware) datetime -> aware datetime in settings.display_timezone."""
return to_utc_aware(dt).astimezone(ZoneInfo(settings.display_timezone))
+17 -15
View File
@@ -29,28 +29,30 @@ def admin_required(f):
@login_required @login_required
@admin_required @admin_required
def user_list(): def user_list():
"""User management page.""" """#50: la gestione utenti vive nella tab «Utenti» di Impostazioni."""
resp = api_client.get("/api/users") return redirect(url_for("admin.settings_page", tab="utenti"))
if isinstance(resp, dict) and resp.get("error"):
flash(_("Errore nel caricamento degli utenti: %(error)s", error=resp.get("detail", "")), "error")
users = []
elif isinstance(resp, list):
users = resp
else:
users = []
return render_template("admin/users.html", users=users)
@admin_bp.route("/settings") @admin_bp.route("/settings")
@login_required @login_required
@admin_required @admin_required
def settings_page(): def settings_page():
"""System settings page.""" """System settings page (admin only), con la tab «Utenti» (#50)."""
resp = api_client.get("/api/settings") resp = api_client.get("/api/settings")
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {} # Solo un dict valido sono impostazioni: un errore o altro tipo = nessuna
return render_template("admin/settings.html", settings=settings) settings = resp if isinstance(resp, dict) and not resp.get("error") else {}
users_resp = api_client.get("/api/users")
if isinstance(users_resp, dict) and users_resp.get("error"):
flash(_("Errore nel caricamento degli utenti: %(error)s", error=users_resp.get("detail", "")), "error")
users = []
elif isinstance(users_resp, list):
users = users_resp
else:
users = []
tab = "utenti" if request.args.get("tab") == "utenti" else "generale"
return render_template("admin/settings.html", settings=settings, users=users, active_tab=tab)
@admin_bp.route("/api/settings", methods=["PUT"]) @admin_bp.route("/api/settings", methods=["PUT"])
@@ -1,18 +1,7 @@
{% extends "base.html" %} {# Pannello utenti: incluso nella tab «Utenti» di Impostazioni (#50) #}
{% block title %}{{ _('Gestione Utenti') }} - TieMeasureFlow{% endblock %}
{% block content %}
<script>window.__users = {{ users|tojson }};</script> <script>window.__users = {{ users|tojson }};</script>
<div class="tmf-page" <div x-data="userManagement(window.__users)">
x-data="userManagement(window.__users)">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Utenti') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci gli utenti del sistema') }}</p>
</div>
<!-- Toolbar --> <!-- Toolbar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6"> <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
@@ -102,6 +91,12 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg> </svg>
</button> </button>
<!-- #51: reset password dall'admin -->
<button @click="openReset(user)" class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="{{ _('Reset password')|tojson_attr }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</button>
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors" <button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
:class="user.active :class="user.active
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20' ? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
@@ -332,210 +327,51 @@
</div> </div>
</div> </div>
<!-- #51: Modal reset password (la password attuale non si mostra mai) -->
<div x-show="showReset" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeReset()">
<div class="absolute inset-0 bg-black/50" @click="closeReset()"></div>
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
{{ _('Reset password') }}: <span x-text="resetUser?.username"></span>
</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Le sessioni aperte dell\'utente verranno chiuse e dovrà accedere con la nuova password.') }}
</p>
<template x-if="!resetDone">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuova password (minimo 8 caratteri)') }}</label>
<div class="flex gap-2">
<input type="text" x-model="resetPw" autocomplete="off"
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
<button @click="generateResetPw()" type="button"
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
{{ _('Genera') }}
</button>
</div>
<template x-if="resetError">
<p class="mt-2 text-sm text-red-600" x-text="resetError"></p>
</template>
</div>
</template>
<template x-if="resetDone">
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
<p class="text-sm text-emerald-700 dark:text-emerald-300">{{ _('Password cambiata. Annotala ora: non verrà mostrata di nuovo.') }}</p>
<p class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="resetPw"></p>
</div>
</template>
<div class="flex justify-end gap-3 mt-5">
<button @click="closeReset()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
<span x-text="resetDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
</button>
<button x-show="!resetDone" @click="confirmReset()" :disabled="saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
{{ _('Imposta password') }}
</button>
</div>
</div>
</div>
</div> </div>
{% endblock %}
{% block extra_js %}
<script>
function userManagement(initialUsers) {
return {
users: initialUsers || [],
search: '',
showModal: false,
showToggleConfirm: false,
isEditing: false,
editingUserId: null,
toggleUser: null,
saving: false,
errorMsg: '',
form: {
username: '',
display_name: '',
email: '',
password: '',
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
},
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
return meta ? meta.content : '';
},
get filteredUsers() {
if (!this.search.trim()) return this.users;
const q = this.search.toLowerCase();
return this.users.filter(u =>
u.username.toLowerCase().includes(q) ||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
(u.email && u.email.toLowerCase().includes(q)) ||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
);
},
openCreateModal() {
this.isEditing = false;
this.editingUserId = null;
this.errorMsg = '';
this.form = {
username: '',
display_name: '',
email: '',
password: '',
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
};
this.showModal = true;
},
openEditModal(user) {
this.isEditing = true;
this.editingUserId = user.id;
this.errorMsg = '';
this.form = {
username: user.username,
display_name: user.display_name || '',
email: user.email || '',
password: '',
roles: [...(user.roles || [])],
is_admin: user.is_admin || false,
language_pref: user.language_pref || 'it',
theme_pref: user.theme_pref || 'light'
};
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errorMsg = '';
},
async saveUser() {
this.saving = true;
this.errorMsg = '';
try {
if (this.isEditing) {
// Update user
const data = {
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nel salvataggio';
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.editingUserId);
if (idx >= 0) this.users[idx] = result;
// Change password if provided
if (this.form.password) {
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ password: this.form.password })
});
if (!pwResp.ok) {
const pwResult = await pwResp.json();
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
return;
}
}
} else {
// Create user
if (!this.form.username || !this.form.display_name || !this.form.password) {
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
return;
}
const data = {
username: this.form.username,
password: this.form.password,
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch('/admin/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nella creazione';
return;
}
this.users.push(result);
}
this.closeModal();
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
toggleActive(user) {
this.toggleUser = user;
this.showToggleConfirm = true;
},
async confirmToggle() {
if (!this.toggleUser) return;
this.saving = true;
try {
const newActive = !this.toggleUser.active;
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ active: newActive })
});
const result = await resp.json();
if (!resp.ok) {
alert(result.detail || 'Errore');
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
if (idx >= 0) this.users[idx] = result;
this.showToggleConfirm = false;
this.toggleUser = null;
} catch (e) {
alert('{{ _("Errore di connessione al server") }}');
} finally {
this.saving = false;
}
}
};
}
</script>
{% endblock %}
@@ -0,0 +1,258 @@
<script>
function userManagement(initialUsers) {
return {
users: initialUsers || [],
search: '',
showModal: false,
showToggleConfirm: false,
// #51: stato del reset password
showReset: false,
resetUser: null,
resetPw: '',
resetDone: false,
resetError: '',
isEditing: false,
editingUserId: null,
toggleUser: null,
saving: false,
errorMsg: '',
form: {
username: '',
display_name: '',
email: '',
password: '',
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
},
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
return meta ? meta.content : '';
},
get filteredUsers() {
if (!this.search.trim()) return this.users;
const q = this.search.toLowerCase();
return this.users.filter(u =>
u.username.toLowerCase().includes(q) ||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
(u.email && u.email.toLowerCase().includes(q)) ||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
);
},
openCreateModal() {
this.isEditing = false;
this.editingUserId = null;
this.errorMsg = '';
this.form = {
username: '',
display_name: '',
email: '',
password: '',
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
};
this.showModal = true;
},
openEditModal(user) {
this.isEditing = true;
this.editingUserId = user.id;
this.errorMsg = '';
this.form = {
username: user.username,
display_name: user.display_name || '',
email: user.email || '',
password: '',
roles: [...(user.roles || [])],
is_admin: user.is_admin || false,
language_pref: user.language_pref || 'it',
theme_pref: user.theme_pref || 'light'
};
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errorMsg = '';
},
async saveUser() {
this.saving = true;
this.errorMsg = '';
try {
if (this.isEditing) {
// Update user
const data = {
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nel salvataggio';
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.editingUserId);
if (idx >= 0) this.users[idx] = result;
// Change password if provided
if (this.form.password) {
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ password: this.form.password })
});
if (!pwResp.ok) {
const pwResult = await pwResp.json();
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
return;
}
}
} else {
// Create user
if (!this.form.username || !this.form.display_name || !this.form.password) {
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
return;
}
const data = {
username: this.form.username,
password: this.form.password,
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch('/admin/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nella creazione';
return;
}
this.users.push(result);
}
this.closeModal();
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
// #51: reset password dall'admin, usa PUT /admin/api/users/<id>/password
openReset(user) {
this.resetUser = user;
this.resetPw = '';
this.resetDone = false;
this.resetError = '';
this.showReset = true;
},
closeReset() {
this.showReset = false;
this.resetPw = ''; // la password generata non resta in memoria
this.resetDone = false;
},
generateResetPw() {
// 12 caratteri senza simboli ambigui (0/O, 1/l/I), da crypto random
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
const buf = new Uint32Array(12);
crypto.getRandomValues(buf);
this.resetPw = Array.from(buf, n => chars[n % chars.length]).join('');
this.resetError = '';
},
async confirmReset() {
if (!this.resetPw || this.resetPw.length < 8) {
this.resetError = {{ _('La password deve avere almeno 8 caratteri')|tojson }};
return;
}
this.saving = true;
this.resetError = '';
try {
const resp = await fetch(`/admin/api/users/${this.resetUser.id}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ password: this.resetPw })
});
const result = await resp.json();
if (!resp.ok) {
this.resetError = result.detail || {{ _('Errore nel cambio password')|tojson }};
return;
}
this.resetDone = true;
} catch (e) {
this.resetError = {{ _('Errore di connessione al server')|tojson }};
} finally {
this.saving = false;
}
},
toggleActive(user) {
this.toggleUser = user;
this.showToggleConfirm = true;
},
async confirmToggle() {
if (!this.toggleUser) return;
this.saving = true;
try {
const newActive = !this.toggleUser.active;
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ active: newActive })
});
const result = await resp.json();
if (!resp.ok) {
alert(result.detail || 'Errore');
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
if (idx >= 0) this.users[idx] = result;
this.showToggleConfirm = false;
this.toggleUser = null;
} catch (e) {
alert('{{ _("Errore di connessione al server") }}');
} finally {
this.saving = false;
}
}
};
}
</script>
@@ -3,17 +3,31 @@
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %} {% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
{% block content %} {% block content %}
<div class="tmf-page tmf-page-narrow" <div class="tmf-page" x-data="{ tab: {{ active_tab|tojson_attr }} }">
x-data="systemSettings()">
<!-- Header --> <!-- Header -->
<div class="mb-6"> <div class="mb-4">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1> <h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p> <p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
</div> </div>
<!-- #50: tab. L'intera pagina e' gia' solo per admin (admin_required) -->
<div class="flex gap-1 mb-6 border-b border-[var(--border-color)]">
<button type="button" @click="tab = 'generale'"
:class="tab === 'generale' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
class="px-4 py-2 -mb-px text-sm font-medium border-b-2 transition-colors">{{ _('Generale') }}</button>
<button type="button" @click="tab = 'utenti'"
:class="tab === 'utenti' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
class="px-4 py-2 -mb-px text-sm font-medium border-b-2 transition-colors">{{ _('Utenti') }}</button>
</div>
<div x-show="tab === 'utenti'" x-cloak>
{% include "admin/_users_panel.html" %}
</div>
<div x-show="tab === 'generale'" class="max-w-4xl mx-auto" x-data="systemSettings()">
<!-- Settings Card --> <!-- Settings Card -->
<div class="tmf-card"> <div class="tmf-card mb-6">
<div class="tmf-card-header flex items-center gap-2"> <div class="tmf-card-header flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
@@ -43,6 +57,57 @@
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }} {{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
</p> </p>
</div> </div>
<!-- #47: durata loop predefinita delle ricette -->
<div class="pt-5 border-t border-[var(--border-color)]">
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Ricette') }}</h3>
<label class="tmf-label" for="default-interval">{{ _('Durata loop predefinita (minuti)') }}</label>
<input id="default-interval" type="number" x-model="defaultInterval" class="tmf-input w-32" min="1" max="1440" placeholder="—">
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Proposta come intervallo di misura nelle nuove ricette (1-1440). Vuoto = nessun predefinito.') }}</p>
</div>
<!-- #48: tolleranze generali -->
<div class="pt-5 border-t border-[var(--border-color)]">
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Tolleranze generali') }}</h3>
<div class="flex items-center gap-6">
<div>
<label class="tmf-label" for="tol-plus">{{ _('Tolleranza +') }}</label>
<input id="tol-plus" type="text" inputmode="decimal" x-model="tolPlus" class="tmf-input w-32" placeholder="—">
</div>
<div>
<label class="tmf-label" for="tol-minus">{{ _('Tolleranza −') }}</label>
<input id="tol-minus" type="text" inputmode="decimal" x-model="tolMinus" class="tmf-input w-32" placeholder="—">
</div>
</div>
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Valori positivi (zero o più) proposti come tolleranza nelle nuove misure. Vuoto = nessun predefinito.') }}</p>
</div>
<!-- #49: anagrafiche (liste di scelta) -->
<div class="pt-5 border-t border-[var(--border-color)]">
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Anagrafiche') }}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<template x-for="lst in ['types', 'units']" :key="lst">
<div>
<label class="tmf-label" x-text="lst === 'types' ? {{ _('Tipi di misura')|tojson_attr }} : {{ _('Unità di misura')|tojson_attr }}"></label>
<ul class="space-y-1 mb-2">
<template x-for="(item, i) in lists[lst]" :key="lst + i">
<li class="flex items-center gap-1 text-sm">
<span class="flex-1 px-2 py-1 rounded bg-[var(--bg-secondary)] text-[var(--text-primary)]" x-text="item"></span>
<button type="button" @click="moveItem(lst, i, -1)" :disabled="i === 0" class="px-1.5 text-[var(--text-muted)] hover:text-primary disabled:opacity-30" title="{{ _('Su') }}">▲</button>
<button type="button" @click="moveItem(lst, i, 1)" :disabled="i === lists[lst].length - 1" class="px-1.5 text-[var(--text-muted)] hover:text-primary disabled:opacity-30" title="{{ _('Giù') }}">▼</button>
<button type="button" @click="removeItem(lst, i)" class="px-1.5 text-[var(--text-muted)] hover:text-red-500" title="{{ _('Rimuovi') }}">✕</button>
</li>
</template>
</ul>
<div class="flex gap-2">
<input type="text" x-model="newItem[lst]" @keydown.enter.prevent="addItem(lst)" class="tmf-input flex-1" placeholder="{{ _('Nuova voce') }}">
<button type="button" @click="addItem(lst)" class="btn btn-secondary">{{ _('Aggiungi') }}</button>
</div>
</div>
</template>
</div>
<p class="text-xs text-[var(--text-muted)] mt-2">{{ _('Massimo 50 voci per lista. Un elenco vuoto = nessuna scelta predefinita.') }}</p>
</div>
</div> </div>
<div class="tmf-card-footer flex items-center justify-between"> <div class="tmf-card-footer flex items-center justify-between">
@@ -68,18 +133,48 @@
</button> </button>
</div> </div>
</div> </div>
</div><!-- /tab generale -->
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
// #49: legge un array JSON di stringhe salvato come impostazione; vuoto/malformato = lista vuota
function tmfParseList(raw) {
try { var a = JSON.parse(raw || '[]'); return Array.isArray(a) ? a.map(String) : []; }
catch (e) { return []; }
}
function systemSettings() { function systemSettings() {
return { return {
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }}, autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
// #47-#49: nuove chiavi (valori stringa, vuoto = nessun default)
defaultInterval: {{ (settings.default_measurement_interval_minutes or '')|tojson }},
tolPlus: {{ (settings.general_tolerance_plus or '')|tojson }},
tolMinus: {{ (settings.general_tolerance_minus or '')|tojson }},
lists: {
types: tmfParseList({{ (settings.list_measurement_types or '')|tojson }}),
units: tmfParseList({{ (settings.list_units or '')|tojson }})
},
newItem: { types: '', units: '' },
saving: false, saving: false,
saved: false, saved: false,
errorMessage: '', errorMessage: '',
addItem(lst) {
var v = (this.newItem[lst] || '').trim();
if (!v) return;
if (this.lists[lst].length >= 50) { this.errorMessage = {{ _('Massimo 50 voci per lista')|tojson }}; return; }
if (this.lists[lst].indexOf(v) === -1) this.lists[lst].push(v);
this.newItem[lst] = '';
},
removeItem(lst, i) { this.lists[lst].splice(i, 1); },
moveItem(lst, i, d) {
var a = this.lists[lst].slice(), j = i + d;
if (j < 0 || j >= a.length) return;
var t = a[i]; a[i] = a[j]; a[j] = t;
this.lists[lst] = a;
},
async save() { async save() {
this.saving = true; this.saving = true;
this.saved = false; this.saved = false;
@@ -87,7 +182,13 @@ function systemSettings() {
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content; var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
var payload = { var payload = {
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : '' auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : '',
default_measurement_interval_minutes: String(this.defaultInterval || '').trim(),
// virgola decimale accettata in input, il backend vuole il punto
general_tolerance_plus: String(this.tolPlus || '').trim().replace(',', '.'),
general_tolerance_minus: String(this.tolMinus || '').trim().replace(',', '.'),
list_measurement_types: this.lists.types.length ? JSON.stringify(this.lists.types) : '',
list_units: this.lists.units.length ? JSON.stringify(this.lists.units) : ''
}; };
try { try {
@@ -111,4 +212,5 @@ function systemSettings() {
}; };
} }
</script> </script>
{% include "admin/_users_script.html" %}
{% endblock %} {% endblock %}
@@ -32,7 +32,6 @@
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }} {{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
{% endif %} {% endif %}
{% if current_user.get('is_admin') %} {% if current_user.get('is_admin') %}
{{ nav_link(url_for('admin.user_list'), _('Utenti'), icon_users, ep == 'admin.user_list', mobile) }}
{{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_list', mobile) }} {{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_list', mobile) }}
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }} {{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
{% endif %} {% endif %}
@@ -0,0 +1,64 @@
"""#50/#51: tab Utenti dentro Impostazioni, redirect da /admin/users, reset password."""
from unittest.mock import MagicMock, patch
import pytest
from tests.conftest import api_get_router
USER = {
"id": 2, "username": "mario", "display_name": "Mario", "email": None,
"roles": ["MeasurementTec"], "is_admin": False, "language_pref": "it",
"theme_pref": "light", "active": True,
}
@pytest.fixture
def mock_admin_api():
mock = MagicMock()
mock.get.side_effect = api_get_router({
"/api/users": [USER],
"/api/settings": {"auto_logout_minutes": "30"},
})
with patch("blueprints.admin.api_client", mock):
yield mock
def test_users_url_redirects_to_settings_tab(logged_in_client, mock_admin_api):
resp = logged_in_client.get("/admin/users")
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/admin/settings?tab=utenti")
def test_settings_has_users_tab_and_reset_for_admin(logged_in_client, mock_admin_api):
resp = logged_in_client.get("/admin/settings?tab=utenti")
assert resp.status_code == 200
html = resp.data.decode("utf-8")
assert "userManagement(" in html # pannello utenti incluso
assert "openReset(user)" in html # pulsante reset password (#51)
assert "mario" in html # utente caricato
assert "tab: " in html and "utenti" in html # tab iniziale richiesta
def test_navbar_has_no_users_link(logged_in_client, mock_admin_api):
html = logged_in_client.get("/admin/settings").data.decode("utf-8")
assert 'href="/admin/users"' not in html
def test_non_admin_cannot_reach_users_tab(client, mock_admin_api):
with client.session_transaction() as sess:
sess["api_key"] = "k"
sess["user"] = {"id": 3, "username": "op", "roles": ["MeasurementTec"],
"is_admin": False, "language_pref": "it", "theme_pref": "light"}
for url in ("/admin/settings?tab=utenti", "/admin/users"):
resp = client.get(url)
assert resp.status_code == 302
assert "/admin/" not in resp.headers["Location"]
mock_admin_api.get.assert_not_called()
def test_password_reset_proxy(logged_in_client, mock_admin_api):
mock_admin_api.put.return_value = {"message": "ok"}
resp = logged_in_client.put("/admin/api/users/2/password", json={"password": "Nuova1234"})
assert resp.status_code == 200
mock_admin_api.put.assert_called_once_with(
"/api/users/2/password", data={"password": "Nuova1234"})
@@ -256,7 +256,7 @@ def test_admin_stations_inline_js_is_valid_in_english(
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api): def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
"""Same guard for /admin/users (reuses the userManagement Alpine pattern).""" """Same guard for the Users tab, now inside /admin/settings (#50)."""
mock_admin_api.get.return_value = [{ mock_admin_api.get.return_value = [{
"id": 1, "username": "admin", "display_name": "Admin", "id": 1, "username": "admin", "display_name": "Admin",
"email": None, "roles": ["Maker"], "is_admin": True, "email": None, "roles": ["Maker"], "is_admin": True,
@@ -264,7 +264,7 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
}] }]
_force_italian(logged_in_client) _force_italian(logged_in_client)
resp = logged_in_client.get("/admin/users") resp = logged_in_client.get("/admin/settings")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.data.decode("utf-8") html = resp.data.decode("utf-8")
@@ -272,9 +272,9 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
assert scripts assert scripts
for i, body in enumerate(scripts): for i, body in enumerate(scripts):
_node_check(body, f"/admin/users script[{i}]") _node_check(body, f"/admin/settings script[{i}]")
_check_alpine_attributes(html, "/admin/users") _check_alpine_attributes(html, "/admin/settings")
@pytest.fixture @pytest.fixture