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