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),
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
"""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
|
||||||
@@ -21,23 +18,6 @@ _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.
|
||||||
|
|
||||||
@@ -61,29 +41,6 @@ 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(
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -176,50 +176,6 @@ 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
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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))
|
|
||||||
@@ -29,30 +29,28 @@ def admin_required(f):
|
|||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def user_list():
|
def user_list():
|
||||||
"""#50: la gestione utenti vive nella tab «Utenti» di Impostazioni."""
|
"""User management page."""
|
||||||
return redirect(url_for("admin.settings_page", tab="utenti"))
|
resp = api_client.get("/api/users")
|
||||||
|
|
||||||
|
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 (admin only), con la tab «Utenti» (#50)."""
|
"""System settings page."""
|
||||||
resp = api_client.get("/api/settings")
|
resp = api_client.get("/api/settings")
|
||||||
# Solo un dict valido sono impostazioni: un errore o altro tipo = nessuna
|
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
|
||||||
settings = resp if isinstance(resp, dict) and not resp.get("error") else {}
|
return render_template("admin/settings.html", settings=settings)
|
||||||
|
|
||||||
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,258 +0,0 @@
|
|||||||
<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,31 +3,17 @@
|
|||||||
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="tmf-page" x-data="{ tab: {{ active_tab|tojson_attr }} }">
|
<div class="tmf-page tmf-page-narrow"
|
||||||
|
x-data="systemSettings()">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-4">
|
<div class="mb-6">
|
||||||
<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 mb-6">
|
<div class="tmf-card">
|
||||||
<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"/>
|
||||||
@@ -57,57 +43,6 @@
|
|||||||
{{ _('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">
|
||||||
@@ -133,48 +68,18 @@
|
|||||||
</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;
|
||||||
@@ -182,13 +87,7 @@ 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 {
|
||||||
@@ -212,5 +111,4 @@ function systemSettings() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% include "admin/_users_script.html" %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+219
-55
@@ -1,7 +1,18 @@
|
|||||||
{# Pannello utenti: incluso nella tab «Utenti» di Impostazioni (#50) #}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ _('Gestione Utenti') }} - TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
<script>window.__users = {{ users|tojson }};</script>
|
<script>window.__users = {{ users|tojson }};</script>
|
||||||
|
|
||||||
<div x-data="userManagement(window.__users)">
|
<div class="tmf-page"
|
||||||
|
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">
|
||||||
@@ -91,12 +102,6 @@
|
|||||||
<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'
|
||||||
@@ -327,51 +332,210 @@
|
|||||||
</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 %}
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
{{ 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 %}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
"""#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 the Users tab, now inside /admin/settings (#50)."""
|
"""Same guard for /admin/users (reuses the userManagement Alpine pattern)."""
|
||||||
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/settings")
|
resp = logged_in_client.get("/admin/users")
|
||||||
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/settings script[{i}]")
|
_node_check(body, f"/admin/users script[{i}]")
|
||||||
|
|
||||||
_check_alpine_attributes(html, "/admin/settings")
|
_check_alpine_attributes(html, "/admin/users")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
Reference in New Issue
Block a user