Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e3316957c | |||
| db5d686938 |
@@ -20,7 +20,8 @@ from src.backend.models.api.production import (
|
|||||||
)
|
)
|
||||||
from src.backend.models.orm.production import ProductionRun
|
from src.backend.models.orm.production import ProductionRun
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.services import auth_service, production_service
|
from src.backend.models.api.monitor import MonitorRun
|
||||||
|
from src.backend.services import auth_service, monitor_service, production_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||||
|
|
||||||
@@ -81,6 +82,27 @@ async def get_current_production_run(
|
|||||||
return await _as_response(db, run) if run is not None else None
|
return await _as_response(db, run) if run is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_high_authority(user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Monitor of all stations (#57): admin or Supervisor (capoturno) only."""
|
||||||
|
if user.is_admin or user.has_role("Supervisor"):
|
||||||
|
return user
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Supervisor role required",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/open", response_model=list[MonitorRun])
|
||||||
|
async def list_open_production_runs(
|
||||||
|
user: User = Depends(_require_high_authority),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Every open run with timer, last event and recent measurements (#57).
|
||||||
|
|
||||||
|
Declared before /{run_id} so "open" is not parsed as a run id.
|
||||||
|
"""
|
||||||
|
return await monitor_service.list_open_runs(db)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
||||||
async def get_production_run(
|
async def get_production_run(
|
||||||
run_id: int,
|
run_id: int,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""API models for the live station monitor (#57)."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorSubtask(BaseModel):
|
||||||
|
"""Mean of the recent measurements of one quote, against its tolerance."""
|
||||||
|
|
||||||
|
subtask_id: int
|
||||||
|
description: str
|
||||||
|
unit: str
|
||||||
|
count: int
|
||||||
|
mean: float
|
||||||
|
ltl: Optional[float] = None
|
||||||
|
utl: Optional[float] = None
|
||||||
|
# True when ltl <= mean <= utl (a missing limit does not constrain).
|
||||||
|
in_tolerance: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorEvent(BaseModel):
|
||||||
|
event_type: str
|
||||||
|
created_at: datetime
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorRun(BaseModel):
|
||||||
|
id: int
|
||||||
|
station_code: str
|
||||||
|
station_name: str
|
||||||
|
recipe_code: str
|
||||||
|
recipe_name: str
|
||||||
|
version_number: int
|
||||||
|
operator: str
|
||||||
|
status: str
|
||||||
|
started_at: datetime
|
||||||
|
duration_seconds: int
|
||||||
|
cycle_count: int
|
||||||
|
seconds_to_next_measurement: Optional[int] = None
|
||||||
|
overdue: bool
|
||||||
|
last_event: Optional[MonitorEvent] = None
|
||||||
|
# Counts over the last MONITOR_WINDOW measurements of the run.
|
||||||
|
measured: int
|
||||||
|
pass_count: int
|
||||||
|
warning_count: int
|
||||||
|
fail_count: int
|
||||||
|
subtasks: list[MonitorSubtask]
|
||||||
|
server_time: datetime
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Live monitor of the open production runs (#57).
|
||||||
|
|
||||||
|
One query per table, whatever the number of runs: measurements and last events are
|
||||||
|
ranked per run with a window function and cut in the database, not in a loop.
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.api.monitor import MonitorEvent, MonitorRun, MonitorSubtask
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import production_service
|
||||||
|
|
||||||
|
# Recent measurements per run that feed counts and means (#57: last N).
|
||||||
|
MONITOR_WINDOW = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _in_tolerance(mean: float, ltl, utl) -> bool:
|
||||||
|
# A missing limit does not constrain.
|
||||||
|
if ltl is not None and mean < float(ltl):
|
||||||
|
return False
|
||||||
|
if utl is not None and mean > float(utl):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
|
||||||
|
runs = (await db.execute(
|
||||||
|
select(ProductionRun)
|
||||||
|
.where(ProductionRun.status.in_(("running", "paused")))
|
||||||
|
.order_by(ProductionRun.started_at)
|
||||||
|
)).scalars().all()
|
||||||
|
if not runs:
|
||||||
|
return []
|
||||||
|
run_ids = [r.id for r in runs]
|
||||||
|
|
||||||
|
# Operators: one query.
|
||||||
|
users = {
|
||||||
|
u.id: u for u in (await db.execute(
|
||||||
|
select(User).where(User.id.in_({r.operator_id for r in runs}))
|
||||||
|
)).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Last event per run: rank desc inside the run, keep rank 1.
|
||||||
|
ev_rank = func.row_number().over(
|
||||||
|
partition_by=ProductionEvent.run_id,
|
||||||
|
order_by=(ProductionEvent.created_at.desc(), ProductionEvent.id.desc()),
|
||||||
|
).label("rn")
|
||||||
|
ev_sub = select(
|
||||||
|
ProductionEvent.run_id.label("run_id"),
|
||||||
|
ProductionEvent.event_type.label("event_type"),
|
||||||
|
ProductionEvent.created_at.label("created_at"),
|
||||||
|
ProductionEvent.note.label("note"),
|
||||||
|
ev_rank,
|
||||||
|
).where(ProductionEvent.run_id.in_(run_ids)).subquery()
|
||||||
|
ev_rows = (await db.execute(
|
||||||
|
select(ev_sub.c.run_id, ev_sub.c.event_type, ev_sub.c.created_at, ev_sub.c.note)
|
||||||
|
.where(ev_sub.c.rn == 1)
|
||||||
|
)).all()
|
||||||
|
last_event = {r.run_id: r for r in ev_rows}
|
||||||
|
|
||||||
|
# Last N measurements per run, joined to their subtask for the limits.
|
||||||
|
m_rank = func.row_number().over(
|
||||||
|
partition_by=Measurement.production_run_id,
|
||||||
|
order_by=(Measurement.measured_at.desc(), Measurement.id.desc()),
|
||||||
|
).label("rn")
|
||||||
|
m_sub = select(
|
||||||
|
Measurement.production_run_id.label("run_id"),
|
||||||
|
Measurement.subtask_id.label("subtask_id"),
|
||||||
|
Measurement.value.label("value"),
|
||||||
|
Measurement.pass_fail.label("pass_fail"),
|
||||||
|
m_rank,
|
||||||
|
).where(Measurement.production_run_id.in_(run_ids)).subquery()
|
||||||
|
m_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
m_sub.c.run_id, m_sub.c.subtask_id, m_sub.c.value, m_sub.c.pass_fail,
|
||||||
|
RecipeSubtask.description, RecipeSubtask.unit,
|
||||||
|
RecipeSubtask.ltl, RecipeSubtask.utl,
|
||||||
|
)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.id == m_sub.c.subtask_id)
|
||||||
|
.where(m_sub.c.rn <= MONITOR_WINDOW)
|
||||||
|
)).all()
|
||||||
|
by_run = defaultdict(list)
|
||||||
|
for row in m_rows:
|
||||||
|
by_run[row.run_id].append(row)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for run in runs:
|
||||||
|
rows = by_run.get(run.id, [])
|
||||||
|
per_sub: dict[int, list] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
per_sub[row.subtask_id].append(row)
|
||||||
|
subtasks = []
|
||||||
|
for sid, group in sorted(per_sub.items()):
|
||||||
|
mean = sum(float(g.value) for g in group) / len(group)
|
||||||
|
first = group[0]
|
||||||
|
subtasks.append(MonitorSubtask(
|
||||||
|
subtask_id=sid, description=first.description, unit=first.unit,
|
||||||
|
count=len(group), mean=round(mean, 6),
|
||||||
|
ltl=float(first.ltl) if first.ltl is not None else None,
|
||||||
|
utl=float(first.utl) if first.utl is not None else None,
|
||||||
|
in_tolerance=_in_tolerance(mean, first.ltl, first.utl),
|
||||||
|
))
|
||||||
|
timer = production_service.describe(run)
|
||||||
|
ev = last_event.get(run.id)
|
||||||
|
operator = users.get(run.operator_id)
|
||||||
|
result.append(MonitorRun(
|
||||||
|
id=run.id,
|
||||||
|
station_code=run.station.code, station_name=run.station.name,
|
||||||
|
recipe_code=run.recipe.code, recipe_name=run.recipe.name,
|
||||||
|
version_number=run.version.version_number,
|
||||||
|
operator=(operator.display_name or operator.username) if operator else "",
|
||||||
|
status=run.status,
|
||||||
|
started_at=run.started_at,
|
||||||
|
duration_seconds=max(0, int((timer["server_time"] - run.started_at).total_seconds())),
|
||||||
|
cycle_count=run.cycle_count,
|
||||||
|
seconds_to_next_measurement=timer["seconds_to_next_measurement"],
|
||||||
|
overdue=timer["overdue"],
|
||||||
|
last_event=MonitorEvent(
|
||||||
|
event_type=ev.event_type, created_at=ev.created_at, note=ev.note,
|
||||||
|
) if ev else None,
|
||||||
|
measured=len(rows),
|
||||||
|
pass_count=sum(1 for g in rows if g.pass_fail == "pass"),
|
||||||
|
warning_count=sum(1 for g in rows if g.pass_fail == "warning"),
|
||||||
|
fail_count=sum(1 for g in rows if g.pass_fail == "fail"),
|
||||||
|
subtasks=subtasks,
|
||||||
|
server_time=timer["server_time"],
|
||||||
|
))
|
||||||
|
return result
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""GET /api/production-runs/open - live monitor of the open runs (#57)."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.tests.conftest import _create_user, auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(db, user, code, status="running", overdue=False, open_=True):
|
||||||
|
"""A station plus a run on a fresh recipe (limits ltl 9.5 / utl 10.5)."""
|
||||||
|
station = Station(code=f"ST-{code}", name=f"Linea {code}", active=True, created_by=user.id)
|
||||||
|
db.add(station)
|
||||||
|
recipe = await create_test_recipe(db, user_id=user.id, code=f"REC-{code}")
|
||||||
|
await db.flush()
|
||||||
|
version = recipe.versions[0]
|
||||||
|
now = datetime.utcnow()
|
||||||
|
run = ProductionRun(
|
||||||
|
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||||
|
operator_id=user.id, status=status if open_ else "closed",
|
||||||
|
started_at=now - timedelta(hours=1),
|
||||||
|
measurement_interval_minutes=30,
|
||||||
|
next_measurement_at=now + (timedelta(minutes=-5) if overdue else timedelta(minutes=20)),
|
||||||
|
cycle_count=3,
|
||||||
|
active_station_id=station.id if open_ else None,
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.flush()
|
||||||
|
db.add(ProductionEvent(run_id=run.id, event_type="start", user_id=user.id))
|
||||||
|
sub = (await db.execute(
|
||||||
|
select(RecipeSubtask).join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
|
||||||
|
.where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return run, sub, version
|
||||||
|
|
||||||
|
|
||||||
|
def _m(run, sub, version, user, value, pf, minutes=0):
|
||||||
|
return Measurement(
|
||||||
|
subtask_id=sub.id, version_id=version.id, measured_by=user.id, value=value,
|
||||||
|
pass_fail=pf, production_run_id=run.id,
|
||||||
|
measured_at=datetime.utcnow() - timedelta(minutes=minutes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_requires_high_authority(client, db_session, measurement_tec_user):
|
||||||
|
resp = await client.get("/api/production-runs/open", headers=auth_headers(measurement_tec_user))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_lists_only_open_runs_with_means(client, db_session, admin_user):
|
||||||
|
sup = await _create_user(db_session, "capo", roles=["Supervisor"])
|
||||||
|
r1, s1, v1 = await _run(db_session, admin_user, "A", overdue=True)
|
||||||
|
r2, s2, v2 = await _run(db_session, admin_user, "B", status="paused")
|
||||||
|
await _run(db_session, admin_user, "C", open_=False)
|
||||||
|
# Run A: mean 10.0 (inside), run B: mean 10.8 (outside, one fail).
|
||||||
|
for i, (val, pf) in enumerate([(9.9, "pass"), (10.1, "pass")]):
|
||||||
|
db_session.add(_m(r1, s1, v1, admin_user, val, pf, i))
|
||||||
|
for i, (val, pf) in enumerate([(10.6, "fail"), (11.0, "fail"), (10.8, "warning")]):
|
||||||
|
db_session.add(_m(r2, s2, v2, admin_user, val, pf, i))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/api/production-runs/open", headers=auth_headers(sup))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = {r["station_code"]: r for r in resp.json()}
|
||||||
|
assert set(body) == {"ST-A", "ST-B"} # closed run C is absent
|
||||||
|
a, b = body["ST-A"], body["ST-B"]
|
||||||
|
assert a["overdue"] is True and a["seconds_to_next_measurement"] < 0
|
||||||
|
assert b["overdue"] is False and b["status"] == "paused"
|
||||||
|
assert a["duration_seconds"] >= 3600 and a["cycle_count"] == 3
|
||||||
|
assert a["last_event"]["event_type"] == "start"
|
||||||
|
assert a["pass_count"] == 2 and a["fail_count"] == 0
|
||||||
|
assert a["subtasks"][0]["mean"] == 10.0 and a["subtasks"][0]["in_tolerance"] is True
|
||||||
|
assert b["fail_count"] == 2 and b["warning_count"] == 1
|
||||||
|
assert round(b["subtasks"][0]["mean"], 3) == 10.8
|
||||||
|
assert b["subtasks"][0]["in_tolerance"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_empty_for_admin(client, admin_user):
|
||||||
|
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
|
||||||
|
assert resp.status_code == 200 and resp.json() == []
|
||||||
@@ -74,12 +74,14 @@ def create_app() -> Flask:
|
|||||||
from blueprints.maker import maker_bp
|
from blueprints.maker import maker_bp
|
||||||
from blueprints.statistics import statistics_bp
|
from blueprints.statistics import statistics_bp
|
||||||
from blueprints.admin import admin_bp
|
from blueprints.admin import admin_bp
|
||||||
|
from blueprints.monitor import monitor_bp # #57 live station monitor
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(measure_bp, url_prefix="/measure")
|
app.register_blueprint(measure_bp, url_prefix="/measure")
|
||||||
app.register_blueprint(maker_bp, url_prefix="/maker")
|
app.register_blueprint(maker_bp, url_prefix="/maker")
|
||||||
app.register_blueprint(statistics_bp, url_prefix="/statistics")
|
app.register_blueprint(statistics_bp, url_prefix="/statistics")
|
||||||
app.register_blueprint(admin_bp, url_prefix="/admin")
|
app.register_blueprint(admin_bp, url_prefix="/admin")
|
||||||
|
app.register_blueprint(monitor_bp, url_prefix="/monitor") # #57
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def _check_secret_key():
|
def _check_secret_key():
|
||||||
|
|||||||
@@ -59,45 +59,6 @@ def _post_login_landing():
|
|||||||
return url_for("auth.profile")
|
return url_for("auth.profile")
|
||||||
|
|
||||||
|
|
||||||
def _complete_login(response, fallback_name):
|
|
||||||
"""Dopo una risposta di login riuscita: sessione, preferenze, impostazioni (#59).
|
|
||||||
|
|
||||||
Comune al login con password e al login con PIN, cosi' i due restano identici.
|
|
||||||
Ritorna l'utente salvato in sessione.
|
|
||||||
"""
|
|
||||||
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
|
|
||||||
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
|
|
||||||
# limite configurato non ha mai effetto (#33).
|
|
||||||
session.permanent = True
|
|
||||||
session["api_key"] = response["api_key"]
|
|
||||||
session["user"] = response["user"]
|
|
||||||
session["user_id"] = response["user"]["id"]
|
|
||||||
|
|
||||||
# Applica preferenze utente
|
|
||||||
user = response["user"]
|
|
||||||
if user.get("language_pref"):
|
|
||||||
session["language"] = user["language_pref"]
|
|
||||||
if user.get("theme_pref"):
|
|
||||||
session["theme"] = user["theme_pref"]
|
|
||||||
|
|
||||||
# Carica impostazioni di sistema
|
|
||||||
settings_resp = api_client.get("/api/settings")
|
|
||||||
if not settings_resp.get("error"):
|
|
||||||
logo_path = settings_resp.get("company_logo_path")
|
|
||||||
if logo_path:
|
|
||||||
session["company_logo"] = logo_path
|
|
||||||
auto_logout = settings_resp.get("auto_logout_minutes")
|
|
||||||
if auto_logout:
|
|
||||||
try:
|
|
||||||
session["auto_logout_minutes"] = int(auto_logout)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", fallback_name)), "success")
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
"""Login page and form handler."""
|
"""Login page and form handler."""
|
||||||
@@ -120,7 +81,35 @@ def login():
|
|||||||
flash(response.get("detail", _("Credenziali non valide")), "error")
|
flash(response.get("detail", _("Credenziali non valide")), "error")
|
||||||
return render_template("auth/login.html")
|
return render_template("auth/login.html")
|
||||||
|
|
||||||
user = _complete_login(response, username)
|
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
|
||||||
|
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
|
||||||
|
# limite configurato non ha mai effetto (#33).
|
||||||
|
session.permanent = True
|
||||||
|
session["api_key"] = response["api_key"]
|
||||||
|
session["user"] = response["user"]
|
||||||
|
session["user_id"] = response["user"]["id"]
|
||||||
|
|
||||||
|
# Applica preferenze utente
|
||||||
|
user = response["user"]
|
||||||
|
if user.get("language_pref"):
|
||||||
|
session["language"] = user["language_pref"]
|
||||||
|
if user.get("theme_pref"):
|
||||||
|
session["theme"] = user["theme_pref"]
|
||||||
|
|
||||||
|
# Carica impostazioni di sistema
|
||||||
|
settings_resp = api_client.get("/api/settings")
|
||||||
|
if not settings_resp.get("error"):
|
||||||
|
logo_path = settings_resp.get("company_logo_path")
|
||||||
|
if logo_path:
|
||||||
|
session["company_logo"] = logo_path
|
||||||
|
auto_logout = settings_resp.get("auto_logout_minutes")
|
||||||
|
if auto_logout:
|
||||||
|
try:
|
||||||
|
session["auto_logout_minutes"] = int(auto_logout)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
||||||
|
|
||||||
# Redirect dopo login (in base al ruolo)
|
# Redirect dopo login (in base al ruolo)
|
||||||
return redirect(_post_login_landing())
|
return redirect(_post_login_landing())
|
||||||
@@ -132,55 +121,6 @@ def login():
|
|||||||
return render_template("auth/login.html")
|
return render_template("auth/login.html")
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/pin-users", methods=["GET"])
|
|
||||||
def pin_users():
|
|
||||||
"""Elenco nomi per la vista Operatore del login (#59): solo id e nome.
|
|
||||||
|
|
||||||
Pubblica come il login; se il backend non risponde o non ha il PIN, lista
|
|
||||||
vuota e la pagina apre il form password.
|
|
||||||
"""
|
|
||||||
if session.get("api_key"):
|
|
||||||
return jsonify([])
|
|
||||||
try:
|
|
||||||
resp = api_client.get("/api/auth/pin-users")
|
|
||||||
except Exception:
|
|
||||||
return jsonify([])
|
|
||||||
if not isinstance(resp, list):
|
|
||||||
return jsonify([])
|
|
||||||
return jsonify([
|
|
||||||
{"id": u.get("id"), "display_name": u.get("display_name", "")}
|
|
||||||
for u in resp if isinstance(u, dict)
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/login-pin", methods=["POST"])
|
|
||||||
def login_pin():
|
|
||||||
"""Login con nome + PIN (#59). JSON in/out; CSRF come il login password
|
|
||||||
(header X-CSRFToken, gestito da CSRFProtect)."""
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
user_id = data.get("user_id")
|
|
||||||
pin = str(data.get("pin", ""))
|
|
||||||
if not isinstance(user_id, int) or not (len(pin) == 4 and pin.isdigit()):
|
|
||||||
return jsonify({"error": True, "status_code": 422, "detail": _("Il PIN deve essere di 4 cifre")}), 422
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = api_client.post("/api/auth/login-pin", data={"user_id": user_id, "pin": pin})
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": True, "status_code": 502,
|
|
||||||
"detail": _("Errore di connessione al server: %(error)s", error=str(e))}), 502
|
|
||||||
|
|
||||||
if response.get("error"):
|
|
||||||
status = response.get("status_code") or 401
|
|
||||||
if status == 401:
|
|
||||||
detail = _("PIN errato")
|
|
||||||
else:
|
|
||||||
detail = response.get("detail") or _("Credenziali non valide")
|
|
||||||
return jsonify({"error": True, "status_code": status, "detail": detail}), status
|
|
||||||
|
|
||||||
_complete_login(response, "") # include il flash di benvenuto
|
|
||||||
return jsonify({"ok": True, "redirect": _post_login_landing()})
|
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/logout", methods=["POST"])
|
@auth_bp.route("/logout", methods=["POST"])
|
||||||
def logout():
|
def logout():
|
||||||
"""Clear session and redirect to login."""
|
"""Clear session and redirect to login."""
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Live monitor of the open production runs (#57).
|
||||||
|
|
||||||
|
Page at /monitor for admin and Supervisor (capoturno); the page polls a JSON proxy
|
||||||
|
of GET /api/production-runs/open every 10 seconds.
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, jsonify, render_template, session
|
||||||
|
|
||||||
|
from blueprints.auth import login_required
|
||||||
|
from services.api_client import api_client
|
||||||
|
|
||||||
|
monitor_bp = Blueprint("monitor", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_required(f):
|
||||||
|
"""Admin or Supervisor only: the same people the backend lets read /open."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
user = session.get("user", {})
|
||||||
|
if not (user.get("is_admin") or "Supervisor" in user.get("roles", [])):
|
||||||
|
abort(403)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
@monitor_bp.route("", strict_slashes=False)
|
||||||
|
@login_required
|
||||||
|
@monitor_required
|
||||||
|
def index():
|
||||||
|
return render_template("monitor/index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@monitor_bp.route("/api/runs")
|
||||||
|
@login_required
|
||||||
|
@monitor_required
|
||||||
|
def api_runs():
|
||||||
|
"""JSON proxy polled by the page."""
|
||||||
|
resp = api_client.get("/api/production-runs/open")
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
body = {"error": True, "detail": resp.get("detail", "")}
|
||||||
|
if resp.get("session_expired"):
|
||||||
|
body["session_expired"] = True
|
||||||
|
return jsonify(body), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp)
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
/* Login Operatore: nome + PIN a 4 cifre (#59).
|
|
||||||
* Componente Alpine globale `pinLogin()`, usato da templates/auth/login.html.
|
|
||||||
* Contratto: GET /auth/pin-users -> [{id, display_name}];
|
|
||||||
* POST /auth/login-pin {user_id, pin} -> {ok, redirect} | {error, detail}. */
|
|
||||||
function pinLogin() {
|
|
||||||
return {
|
|
||||||
mode: 'pin', // vista Operatore di default; se l'elenco e' vuoto/fallisce passa a 'password'
|
|
||||||
users: [],
|
|
||||||
selected: null,
|
|
||||||
pin: '',
|
|
||||||
error: '',
|
|
||||||
busy: false,
|
|
||||||
|
|
||||||
init: function () {
|
|
||||||
var self = this;
|
|
||||||
fetch(window.TMF_PIN.usersUrl, { headers: { 'Accept': 'application/json' } })
|
|
||||||
.then(function (r) { return r.ok ? r.json() : []; })
|
|
||||||
.then(function (list) {
|
|
||||||
self.users = Array.isArray(list) ? list : [];
|
|
||||||
if (!self.users.length) self.mode = 'password'; // elenco vuoto: form password
|
|
||||||
})
|
|
||||||
.catch(function () { self.users = []; self.mode = 'password'; });
|
|
||||||
},
|
|
||||||
select: function (u) { this.selected = u; this.pin = ''; this.error = ''; },
|
|
||||||
back: function () { this.selected = null; this.pin = ''; this.error = ''; },
|
|
||||||
del: function () { this.pin = this.pin.slice(0, -1); },
|
|
||||||
press: function (d) {
|
|
||||||
if (this.busy || this.pin.length >= 4) return;
|
|
||||||
this.pin += d;
|
|
||||||
this.error = '';
|
|
||||||
if (this.pin.length === 4) this.submit(); // invio automatico alla quarta cifra
|
|
||||||
},
|
|
||||||
submit: function () {
|
|
||||||
var self = this;
|
|
||||||
var csrf = document.querySelector('meta[name=csrf-token]');
|
|
||||||
self.busy = true;
|
|
||||||
fetch(window.TMF_PIN.loginUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-CSRFToken': (csrf && csrf.content) || ''
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ user_id: self.selected.id, pin: self.pin })
|
|
||||||
})
|
|
||||||
.then(function (r) { return r.json().catch(function () { return {}; }); })
|
|
||||||
.then(function (d) {
|
|
||||||
if (d.ok && d.redirect) { window.location.href = d.redirect; return; }
|
|
||||||
self.error = d.detail || window.TMF_PIN.msgNet; // 401 PIN errato, 423 blocco con ora
|
|
||||||
self.pin = '';
|
|
||||||
self.busy = false;
|
|
||||||
})
|
|
||||||
.catch(function () {
|
|
||||||
self.error = window.TMF_PIN.msgNet;
|
|
||||||
self.pin = '';
|
|
||||||
self.busy = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -26,50 +26,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-data="pinLogin()" x-init="init()">
|
<!-- Login Form -->
|
||||||
<!-- Vista Operatore: nome + PIN (#59) -->
|
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
||||||
<div x-show="mode === 'pin'" x-cloak>
|
|
||||||
<!-- Elenco nomi -->
|
|
||||||
<div x-show="!selected">
|
|
||||||
<p class="text-sm text-center text-[var(--text-secondary)] mb-4">{{ _('Chi sei?') }}</p>
|
|
||||||
<div class="grid grid-cols-2 gap-3" id="pin-user-list">
|
|
||||||
<template x-for="u in users" :key="u.id">
|
|
||||||
<button type="button" @click="select(u)"
|
|
||||||
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Tastierino -->
|
|
||||||
<div x-show="selected" id="pin-pad">
|
|
||||||
<p class="text-center font-semibold mb-1" x-text="selected && selected.display_name"></p>
|
|
||||||
<p class="text-sm text-center text-[var(--text-secondary)] mb-3">{{ _('Inserisci il PIN') }}</p>
|
|
||||||
<div class="flex justify-center gap-3 mb-3" aria-hidden="true">
|
|
||||||
<template x-for="i in 4" :key="i">
|
|
||||||
<span class="h-4 w-4 rounded-full border-2 border-[var(--border-color)]"
|
|
||||||
:class="pin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-center text-red-600 min-h-[1.25rem] mb-2" role="alert" x-text="error"></p>
|
|
||||||
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
|
|
||||||
<template x-for="d in ['1','2','3','4','5','6','7','8','9']" :key="d">
|
|
||||||
<button type="button" @click="press(d)" :disabled="busy"
|
|
||||||
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
|
|
||||||
</template>
|
|
||||||
<button type="button" @click="back()" :disabled="busy"
|
|
||||||
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
|
|
||||||
<button type="button" @click="press('0')" :disabled="busy"
|
|
||||||
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
|
|
||||||
<button type="button" @click="del()" :disabled="busy"
|
|
||||||
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-6 text-center">
|
|
||||||
<a href="#" @click.prevent="mode = 'password'" class="text-sm text-primary-600 hover:underline">{{ _('Accedi con password') }}</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login Form (password: admin e chi non ha PIN) -->
|
|
||||||
<form x-show="mode === 'password'" method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<!-- Username -->
|
<!-- Username -->
|
||||||
<div>
|
<div>
|
||||||
@@ -140,11 +98,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-4 text-center" x-show="mode === 'password' && users.length" x-cloak>
|
|
||||||
<a href="#" @click.prevent="mode = 'pin'" class="text-sm text-primary-600 hover:underline">{{ _('Accedi con PIN') }}</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Help Text -->
|
<!-- Help Text -->
|
||||||
<div class="mt-6 text-center">
|
<div class="mt-6 text-center">
|
||||||
<p class="text-xs text-[var(--text-muted)]">
|
<p class="text-xs text-[var(--text-muted)]">
|
||||||
@@ -162,15 +115,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
/* Configurazione e testi per pin-pad.js (#59) */
|
|
||||||
window.TMF_PIN = {
|
|
||||||
usersUrl: {{ url_for('auth.pin_users')|tojson }},
|
|
||||||
loginUrl: {{ url_for('auth.login_pin')|tojson }},
|
|
||||||
msgNet: {{ _('Errore di connessione al server')|tojson }}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<script src="{{ url_for('static', filename='js/pin-pad.js') }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ _("Monitor stazioni") }} — TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="tmf-page" x-data="stationMonitor()" x-init="init()">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _("Monitor stazioni") }}</h1>
|
||||||
|
<p class="text-sm text-steel mt-1">{{ _("Produzioni aperte, aggiornate ogni 10 secondi") }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-steel" x-text="updatedLabel"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="error" class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm" x-text="error"></div>
|
||||||
|
|
||||||
|
<div x-show="loaded && runs.length === 0 && !error"
|
||||||
|
class="p-8 text-center text-steel bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)]">
|
||||||
|
{{ _("Nessuna stazione in produzione") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
<template x-for="r in runs" :key="r.id">
|
||||||
|
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] border-l-8 shadow-sm p-4"
|
||||||
|
:class="borderClass(r)">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-bold text-[var(--text-primary)]" x-text="r.station_code + ' — ' + r.station_name"></div>
|
||||||
|
<div class="text-sm text-steel" x-text="r.recipe_code + ' ' + r.recipe_name + ' (v' + r.version_number + ')'"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-semibold px-2 py-1 rounded"
|
||||||
|
:class="r.overdue ? 'bg-red-600 text-white animate-pulse' : 'bg-gray-200 text-gray-700'"
|
||||||
|
x-text="statusLabel(r)"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-sm">
|
||||||
|
<dt class="text-steel">{{ _("Operatore") }}</dt><dd x-text="r.operator"></dd>
|
||||||
|
<dt class="text-steel">{{ _("Avviata alle") }}</dt><dd x-text="startedAt(r)"></dd>
|
||||||
|
<dt class="text-steel">{{ _("Durata") }}</dt><dd x-text="fmt(r.duration_seconds)"></dd>
|
||||||
|
<dt class="text-steel">{{ _("Cicli") }}</dt><dd x-text="r.cycle_count"></dd>
|
||||||
|
<dt class="text-steel">{{ _("Prossima misura") }}</dt>
|
||||||
|
<dd :class="r.overdue ? 'text-red-600 font-bold' : ''" x-text="timerLabel(r)"></dd>
|
||||||
|
<dt class="text-steel">{{ _("Ultimo evento") }}</dt>
|
||||||
|
<dd x-text="r.last_event ? r.last_event.event_type : '-'"></dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="mt-3 flex gap-2 text-xs font-semibold">
|
||||||
|
<span class="px-2 py-1 rounded bg-green-100 text-green-800" x-text="'OK ' + r.pass_count"></span>
|
||||||
|
<span class="px-2 py-1 rounded bg-yellow-100 text-yellow-800" x-text="'{{ _("Avviso") }} ' + r.warning_count"></span>
|
||||||
|
<span class="px-2 py-1 rounded bg-red-100 text-red-800" x-text="'NOK ' + r.fail_count"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="mt-3 space-y-1 text-sm">
|
||||||
|
<template x-for="s in r.subtasks" :key="s.subtask_id">
|
||||||
|
<li class="flex justify-between gap-2">
|
||||||
|
<span class="truncate" x-text="s.description"></span>
|
||||||
|
<span class="font-mono whitespace-nowrap"
|
||||||
|
:class="s.in_tolerance ? 'text-green-700' : 'text-red-700 font-bold'"
|
||||||
|
x-text="s.mean.toFixed(3) + ' ' + s.unit"></span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function stationMonitor() {
|
||||||
|
return {
|
||||||
|
runs: [], loaded: false, error: '', updatedLabel: '',
|
||||||
|
init() { this.load(); setInterval(() => this.load(), 10000); },
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/monitor/api/runs');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || data.error) { this.error = data.detail || {{ _("Errore di caricamento")|tojson }}; return; }
|
||||||
|
this.runs = data; this.error = ''; this.loaded = true;
|
||||||
|
this.updatedLabel = new Date().toLocaleTimeString();
|
||||||
|
} catch (e) { this.error = {{ _("Errore di rete")|tojson }}; }
|
||||||
|
},
|
||||||
|
// Green in tolerance, yellow with warnings, red with fails or a mean out of tolerance.
|
||||||
|
borderClass(r) {
|
||||||
|
if (r.fail_count > 0 || r.subtasks.some(s => !s.in_tolerance)) return 'border-l-red-600';
|
||||||
|
if (r.warning_count > 0) return 'border-l-yellow-500';
|
||||||
|
return 'border-l-green-600';
|
||||||
|
},
|
||||||
|
statusLabel(r) {
|
||||||
|
if (r.overdue) return {{ _("IN RITARDO")|tojson }};
|
||||||
|
return r.status === 'paused' ? {{ _("In pausa")|tojson }} : {{ _("In corso")|tojson }};
|
||||||
|
},
|
||||||
|
startedAt(r) { return new Date(r.started_at + 'Z').toLocaleTimeString(); },
|
||||||
|
timerLabel(r) {
|
||||||
|
if (r.seconds_to_next_measurement === null) return '-';
|
||||||
|
const s = r.seconds_to_next_measurement;
|
||||||
|
return (s < 0 ? '-' : '') + this.fmt(Math.abs(s));
|
||||||
|
},
|
||||||
|
fmt(sec) {
|
||||||
|
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
||||||
|
return (h ? h + 'h ' : '') + m + 'm ' + String(s).padStart(2, '0') + 's';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -136,67 +136,3 @@ class TestLanguageSwitch:
|
|||||||
with client.session_transaction() as sess:
|
with client.session_transaction() as sess:
|
||||||
# Invalid language should NOT be stored
|
# Invalid language should NOT be stored
|
||||||
assert sess.get("language") != "xx"
|
assert sess.get("language") != "xx"
|
||||||
|
|
||||||
|
|
||||||
class TestPinLogin:
|
|
||||||
"""Login Operatore con nome + PIN (#59)."""
|
|
||||||
|
|
||||||
USER = {
|
|
||||||
"id": 7, "username": "mario", "display_name": "Mario Rossi",
|
|
||||||
"roles": ["MeasurementTec"], "is_admin": False,
|
|
||||||
"language_pref": "it", "theme_pref": "light", "active": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_login_page_has_pin_view_and_password_link(self, client):
|
|
||||||
resp = client.get("/auth/login")
|
|
||||||
assert b"pin-pad.js" in resp.data
|
|
||||||
assert b"pin-pad" in resp.data # tastierino presente nel markup
|
|
||||||
assert b'name="password"' in resp.data # form password ancora presente
|
|
||||||
|
|
||||||
def test_pin_users_listed(self, client, mock_api_client):
|
|
||||||
mock_api_client.get.return_value = [
|
|
||||||
{"id": 7, "display_name": "Mario Rossi", "secret": "x"},
|
|
||||||
]
|
|
||||||
resp = client.get("/auth/pin-users")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.get_json() == [{"id": 7, "display_name": "Mario Rossi"}]
|
|
||||||
|
|
||||||
def test_pin_users_empty_on_error(self, client, mock_api_client):
|
|
||||||
mock_api_client.get.return_value = {"error": True, "detail": "boom"}
|
|
||||||
assert client.get("/auth/pin-users").get_json() == []
|
|
||||||
|
|
||||||
def test_pin_users_empty_list_falls_back(self, client, mock_api_client):
|
|
||||||
mock_api_client.get.return_value = []
|
|
||||||
assert client.get("/auth/pin-users").get_json() == []
|
|
||||||
|
|
||||||
def test_login_pin_success_sets_session(self, client, mock_api_client):
|
|
||||||
mock_api_client.post.return_value = {"api_key": "pin-key", "user": self.USER}
|
|
||||||
mock_api_client.get.return_value = {}
|
|
||||||
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "1234"})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.get_json()["ok"] is True
|
|
||||||
assert resp.get_json()["redirect"]
|
|
||||||
mock_api_client.post.assert_called_once_with(
|
|
||||||
"/api/auth/login-pin", data={"user_id": 7, "pin": "1234"})
|
|
||||||
with client.session_transaction() as sess:
|
|
||||||
assert sess["api_key"] == "pin-key"
|
|
||||||
assert sess["user_id"] == 7
|
|
||||||
assert sess.permanent is True
|
|
||||||
|
|
||||||
def test_login_pin_wrong_pin_401(self, client, mock_api_client):
|
|
||||||
mock_api_client.post.return_value = {"error": True, "status_code": 401, "detail": "x"}
|
|
||||||
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
|
|
||||||
assert resp.status_code == 401
|
|
||||||
assert resp.get_json()["detail"] == "PIN errato"
|
|
||||||
|
|
||||||
def test_login_pin_locked_423_shows_detail(self, client, mock_api_client):
|
|
||||||
mock_api_client.post.return_value = {
|
|
||||||
"error": True, "status_code": 423, "detail": "Utente bloccato fino alle 10:15"}
|
|
||||||
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
|
|
||||||
assert resp.status_code == 423
|
|
||||||
assert "10:15" in resp.get_json()["detail"]
|
|
||||||
|
|
||||||
def test_login_pin_invalid_pin_422(self, client, mock_api_client):
|
|
||||||
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "12"})
|
|
||||||
assert resp.status_code == 422
|
|
||||||
mock_api_client.post.assert_not_called()
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Live station monitor page and JSON proxy (#57)."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_api():
|
||||||
|
with patch("blueprints.monitor.api_client") as m:
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, roles, is_admin=False):
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["api_key"] = "k"
|
||||||
|
sess["user"] = {"id": 2, "username": "u", "display_name": "U", "roles": roles,
|
||||||
|
"is_admin": is_admin, "language_pref": "en", "theme_pref": "light",
|
||||||
|
"active": True}
|
||||||
|
sess["language"] = "en"
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_ok_for_supervisor(client, mock_api):
|
||||||
|
_login(client, ["Supervisor"])
|
||||||
|
resp = client.get("/monitor")
|
||||||
|
assert resp.status_code == 200 and b"stationMonitor" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_forbidden_for_operator(client, mock_api):
|
||||||
|
_login(client, ["MeasurementTec"])
|
||||||
|
assert client.get("/monitor").status_code == 403
|
||||||
|
assert client.get("/monitor/api/runs").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_returns_backend_json(client, mock_api):
|
||||||
|
_login(client, ["Supervisor"])
|
||||||
|
mock_api.get.return_value = [{"id": 1, "station_code": "ST-1"}]
|
||||||
|
resp = client.get("/monitor/api/runs")
|
||||||
|
assert resp.status_code == 200 and resp.get_json()[0]["station_code"] == "ST-1"
|
||||||
|
mock_api.get.assert_called_once_with("/api/production-runs/open")
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_passes_backend_error(client, mock_api):
|
||||||
|
_login(client, [], is_admin=True)
|
||||||
|
mock_api.get.return_value = {"error": True, "status_code": 403, "detail": "no"}
|
||||||
|
resp = client.get("/monitor/api/runs")
|
||||||
|
assert resp.status_code == 403 and resp.get_json()["error"] is True
|
||||||
Reference in New Issue
Block a user