Compare commits

..

1 Commits

Author SHA1 Message Date
Adriano c764cf73c6 Login Operatore con nome + PIN: elenco nomi, tastierino, route login-pin (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:23:51 +02:00
11 changed files with 277 additions and 258 deletions
-4
View File
@@ -23,7 +23,6 @@ async def download_spc_report(
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Response: ) -> Response:
@@ -33,7 +32,6 @@ async def download_spc_report(
db, recipe_id, subtask_id, db, recipe_id, subtask_id,
version_id, date_from, date_to, version_id, date_from, date_to,
operator_id, lot_number, serial_number, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
@@ -59,7 +57,6 @@ async def download_measurement_report(
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Response: ) -> Response:
@@ -69,7 +66,6 @@ async def download_measurement_report(
db, recipe_id, subtask_id, db, recipe_id, subtask_id,
version_id, date_from, date_to, version_id, date_from, date_to,
operator_id, lot_number, serial_number, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
+1 -60
View File
@@ -3,13 +3,12 @@ from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, select from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_metrologist from src.backend.api.middleware.api_key import require_metrologist
from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import RecipeVersion from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
@@ -39,7 +38,6 @@ async def _query_measurements(
operator_id: int | None = None, operator_id: int | None = None,
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
production_run_id: int | None = None, # #58: solo le misure di una sessione
) -> list[Measurement]: ) -> list[Measurement]:
"""Query measurements with common filters. """Query measurements with common filters.
@@ -69,9 +67,6 @@ async def _query_measurements(
if serial_number is not None: if serial_number is not None:
filters.append(Measurement.serial_number == serial_number) filters.append(Measurement.serial_number == serial_number)
if production_run_id is not None:
filters.append(Measurement.production_run_id == production_run_id)
query = ( query = (
select(Measurement) select(Measurement)
.where(and_(*filters) if filters else True) .where(and_(*filters) if filters else True)
@@ -111,7 +106,6 @@ async def get_summary(
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> SummaryData: ) -> SummaryData:
@@ -119,7 +113,6 @@ async def get_summary(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
pass_fail_values = [m.pass_fail for m in measurements] pass_fail_values = [m.pass_fail for m in measurements]
return compute_summary(pass_fail_values) return compute_summary(pass_fail_values)
@@ -135,7 +128,6 @@ async def get_capability(
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> CapabilityData: ) -> CapabilityData:
@@ -143,7 +135,6 @@ async def get_capability(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
values = [float(m.value) for m in measurements] values = [float(m.value) for m in measurements]
tol = await _get_subtask_tolerances(db, subtask_id) tol = await _get_subtask_tolerances(db, subtask_id)
@@ -160,7 +151,6 @@ async def get_control_chart(
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ControlChartData: ) -> ControlChartData:
@@ -168,7 +158,6 @@ async def get_control_chart(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
values = [float(m.value) for m in measurements] values = [float(m.value) for m in measurements]
timestamps = [m.measured_at for m in measurements] timestamps = [m.measured_at for m in measurements]
@@ -189,7 +178,6 @@ async def get_histogram(
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
n_bins: int = Query(20, ge=5, le=100), n_bins: int = Query(20, ge=5, le=100),
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -198,7 +186,6 @@ async def get_histogram(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
values = [float(m.value) for m in measurements] values = [float(m.value) for m in measurements]
return compute_histogram(values, n_bins) return compute_histogram(values, n_bins)
@@ -248,49 +235,3 @@ async def get_recipe_subtasks(
}) })
return subtasks_list return subtasks_list
@router.get("/runs")
async def get_recipe_runs(
recipe_id: int = Query(...),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> list[dict]:
"""#58: production runs of a recipe (for the "Sessione" filter), newest first."""
runs = (await db.execute(
select(ProductionRun)
.where(ProductionRun.recipe_id == recipe_id)
.order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc())
)).scalars().all()
# #58: conta solo le misure dei run della ricetta, non tutta la tabella.
run_ids = [r.id for r in runs]
counts = {}
if run_ids:
counts = dict((await db.execute(
select(Measurement.production_run_id, func.count(Measurement.id))
.where(Measurement.production_run_id.in_(run_ids))
.group_by(Measurement.production_run_id)
)).all())
operator_ids = {r.operator_id for r in runs}
names = {}
if operator_ids:
names = dict((await db.execute(
select(User.id, User.display_name).where(User.id.in_(operator_ids))
)).all())
return [
{
"id": r.id,
"station_id": r.station_id,
"station_name": r.station.name if r.station else None,
"operator_id": r.operator_id,
"operator_name": names.get(r.operator_id),
"started_at": r.started_at.isoformat() if r.started_at else None,
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
"status": r.status,
"lot_number": r.lot_number,
"serial_number": r.serial_number,
"cycle_count": r.cycle_count,
"measurement_count": counts.get(r.id, 0),
}
for r in runs
]
-12
View File
@@ -48,7 +48,6 @@ async def _query_measurements(
operator_id: int | None = None, operator_id: int | None = None,
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
production_run_id: int | None = None, # #58
) -> list[Measurement]: ) -> list[Measurement]:
"""Query measurements with common filters (duplicated from statistics router).""" """Query measurements with common filters (duplicated from statistics router)."""
filters = [] filters = []
@@ -74,9 +73,6 @@ async def _query_measurements(
if serial_number is not None: if serial_number is not None:
filters.append(Measurement.serial_number == serial_number) filters.append(Measurement.serial_number == serial_number)
if production_run_id is not None:
filters.append(Measurement.production_run_id == production_run_id)
query = ( query = (
select(Measurement) select(Measurement)
.where(and_(*filters) if filters else True) .where(and_(*filters) if filters else True)
@@ -285,7 +281,6 @@ async def generate_spc_report(
operator_id: int | None = None, operator_id: int | None = None,
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
production_run_id: int | None = None, # #58
) -> bytes: ) -> bytes:
"""Generate SPC PDF report and return bytes. """Generate SPC PDF report and return bytes.
@@ -295,7 +290,6 @@ async def generate_spc_report(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
# 2. Compute SPC data # 2. Compute SPC data
@@ -342,8 +336,6 @@ async def generate_spc_report(
filters_desc.append(f"Lot: {lot_number}") filters_desc.append(f"Lot: {lot_number}")
if serial_number: if serial_number:
filters_desc.append(f"Serial: {serial_number}") filters_desc.append(f"Serial: {serial_number}")
if production_run_id is not None:
filters_desc.append(f"Run: {production_run_id}") # #58
# 6. Render HTML template # 6. Render HTML template
template = _jinja_env.get_template("spc_report.html") template = _jinja_env.get_template("spc_report.html")
@@ -375,14 +367,12 @@ async def generate_measurement_report(
operator_id: int | None = None, operator_id: int | None = None,
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
production_run_id: int | None = None, # #58
) -> bytes: ) -> bytes:
"""Generate measurement table PDF report and return bytes.""" """Generate measurement table PDF report and return bytes."""
# 1. Query measurements # 1. Query measurements
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
# 2. Get recipe info # 2. Get recipe info
@@ -437,8 +427,6 @@ async def generate_measurement_report(
filters_desc.append(f"Lot: {lot_number}") filters_desc.append(f"Lot: {lot_number}")
if serial_number: if serial_number:
filters_desc.append(f"Serial: {serial_number}") filters_desc.append(f"Serial: {serial_number}")
if production_run_id is not None:
filters_desc.append(f"Run: {production_run_id}") # #58
# 7. Render template # 7. Render template
template = _jinja_env.get_template("measurement_report.html") template = _jinja_env.get_template("measurement_report.html")
-76
View File
@@ -211,79 +211,3 @@ class TestDateFilter:
assert resp.status_code == 200 assert resp.status_code == 200
# All measurements should be within this wide range # All measurements should be within this wide range
assert resp.json()["total"] >= 2 assert resp.json()["total"] >= 2
class TestRunFilter:
"""#58: statistiche per sessione di produzione."""
async def _setup(self, db_session, user, metro):
from datetime import datetime
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.station import Station
from src.backend.models.orm.recipe import RecipeVersion
from sqlalchemy import select
recipe = await create_test_recipe(db_session, user.id)
version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().first()
station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id)
db_session.add(station)
await db_session.commit()
runs = []
for i, lot in enumerate(["L1", "L2"]):
run = ProductionRun(
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
operator_id=user.id, status="closed", lot_number=lot,
started_at=datetime(2026, 1, 1 + i),
)
db_session.add(run)
runs.append(run)
await db_session.commit()
return recipe, runs
async def test_filter_by_run_and_unfiltered(
self, client, measurement_tec_user, metrologist_user, db_session,
):
from src.backend.models.orm.measurement import Measurement
recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user)
subtask_id, version_id = await _seed_measurements(
client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2],
)
# Assegna le prime due misure al run 1, la terza al run 2.
from sqlalchemy import select
ms = (await db_session.execute(
select(Measurement).order_by(Measurement.id)
)).scalars().all()
ms[0].production_run_id = runs[0].id
ms[1].production_run_id = runs[0].id
ms[2].production_run_id = runs[1].id
await db_session.commit()
h = auth_headers(metrologist_user)
r1 = await client.get("/api/statistics/summary", headers=h,
params={"recipe_id": recipe.id, "production_run_id": runs[0].id})
assert r1.json()["total"] == 2
r2 = await client.get("/api/statistics/summary", headers=h,
params={"recipe_id": recipe.id, "production_run_id": runs[1].id})
assert r2.json()["total"] == 1
r_all = await client.get("/api/statistics/summary", headers=h,
params={"recipe_id": recipe.id})
assert r_all.json()["total"] == 3
runs_resp = await client.get("/api/statistics/runs", headers=h,
params={"recipe_id": recipe.id})
assert runs_resp.status_code == 200
data = runs_resp.json()
assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima
assert [r["measurement_count"] for r in data] == [1, 2]
assert data[0]["lot_number"] == "L2"
assert data[0]["station_name"] == "Stazione 58"
assert data[0]["closed_at"] is None
async def test_runs_require_metrologist(
self, client, measurement_tec_user, db_session,
):
resp = await client.get("/api/statistics/runs",
headers=auth_headers(measurement_tec_user),
params={"recipe_id": 1})
assert resp.status_code == 403
+89 -29
View File
@@ -59,6 +59,45 @@ 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."""
@@ -81,35 +120,7 @@ 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")
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME user = _complete_login(response, username)
# (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())
@@ -121,6 +132,55 @@ 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."""
@@ -50,7 +50,7 @@ def _proxy_statistics(endpoint: str):
for key in [ for key in [
"recipe_id", "version_id", "subtask_id", "recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id", "date_from", "date_to", "operator_id",
"lot_number", "serial_number", "n_bins", "production_run_id", # #58 "lot_number", "serial_number", "n_bins",
]: ]:
val = request.args.get(key) val = request.args.get(key)
if val is not None and val != "": if val is not None and val != "":
@@ -127,27 +127,6 @@ def api_subtasks():
return jsonify(resp) return jsonify(resp)
@statistics_bp.route("/api/runs")
@login_required
@role_required("Metrologist")
def api_runs():
"""Proxy: production runs of a recipe (#58, "Sessione" filter)."""
params = {}
recipe_id = request.args.get("recipe_id")
if recipe_id:
params["recipe_id"] = recipe_id
resp = api_client.get("/api/statistics/runs", params=params)
if isinstance(resp, dict) and resp.get("error"):
error_body = {"error": True, "detail": resp.get("detail", "")}
if resp.get("session_expired"):
error_body["session_expired"] = True
return jsonify(error_body), resp.get("status_code", 500)
return jsonify(resp)
# ============================================================================ # ============================================================================
# PROXY REPORT PDF → FastAPI # PROXY REPORT PDF → FastAPI
# ============================================================================ # ============================================================================
@@ -158,7 +137,7 @@ def _proxy_report(endpoint: str):
for key in [ for key in [
"recipe_id", "version_id", "subtask_id", "recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id", "date_from", "date_to", "operator_id",
"lot_number", "serial_number", "production_run_id", # #58 "lot_number", "serial_number",
]: ]:
val = request.args.get(key) val = request.args.get(key)
if val is not None and val != "": if val is not None and val != "":
@@ -0,0 +1,60 @@
/* 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,8 +26,50 @@
</p> </p>
</div> </div>
<!-- Login Form --> <div x-data="pinLogin()" x-init="init()">
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6"> <!-- Vista Operatore: nome + PIN (#59) -->
<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>
@@ -98,6 +140,11 @@
</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)]">
@@ -115,3 +162,15 @@
</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 %}
@@ -35,19 +35,6 @@
</select> </select>
</div> </div>
<!-- Sessione di produzione (#58) -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Sessione") }}</label>
<select x-model="selectedRunId"
:disabled="runs.length === 0"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
<option value="">{{ _("Tutte le sessioni") }}</option>
<template x-for="r in runs" :key="r.id">
<option :value="r.id" x-text="runLabel(r)"></option>
</template>
</select>
</div>
<!-- Subtask --> <!-- Subtask -->
<div> <div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label> <label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
@@ -271,8 +258,6 @@ function spcDashboard() {
// Filter state // Filter state
selectedRecipeId: '', selectedRecipeId: '',
selectedSubtaskId: '', selectedSubtaskId: '',
selectedRunId: '', // #58
runs: [], // #58 sessioni della ricetta
dateFrom: '', dateFrom: '',
dateTo: '', dateTo: '',
subtasks: [], subtasks: [],
@@ -296,8 +281,6 @@ function spcDashboard() {
async onRecipeChange() { async onRecipeChange() {
this.selectedSubtaskId = ''; this.selectedSubtaskId = '';
this.selectedRunId = ''; // #58
this.runs = [];
this.subtasks = []; this.subtasks = [];
this.hasData = false; this.hasData = false;
this.summary = null; this.summary = null;
@@ -307,15 +290,6 @@ function spcDashboard() {
if (!this.selectedRecipeId) return; if (!this.selectedRecipeId) return;
// #58: sessioni della ricetta per il filtro «Sessione»
try {
const rresp = await fetch('/statistics/api/runs?recipe_id=' + this.selectedRecipeId);
const rdata = await rresp.json();
if (Array.isArray(rdata)) this.runs = rdata;
} catch (e) {
console.error('Error loading runs:', e);
}
// Load subtasks for dropdown // Load subtasks for dropdown
try { try {
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId); const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
@@ -328,12 +302,6 @@ function spcDashboard() {
} }
}, },
// #58: etichetta sessione = data, stazione, operatore, lotto
runLabel(r) {
const d = r.started_at ? new Date(r.started_at + (r.started_at.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
return [d, r.station_name, r.operator_name, r.lot_number].filter(Boolean).join(' · ');
},
async loadData() { async loadData() {
if (!this.selectedRecipeId) return; if (!this.selectedRecipeId) return;
@@ -344,7 +312,6 @@ function spcDashboard() {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId); params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId); if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString()); if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString()); if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
@@ -450,7 +417,6 @@ function spcDashboard() {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId); params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId); if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString()); if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString()); if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
+64
View File
@@ -136,3 +136,67 @@ 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()
@@ -112,21 +112,3 @@ class TestSPCProxy:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.get_json() data = resp.get_json()
assert "cp" in data assert "cp" in data
class TestRunsProxy:
"""#58: filtro per sessione di produzione."""
def test_runs_proxy(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = [{"id": 5, "measurement_count": 3}]
resp = logged_in_client.get("/statistics/api/runs?recipe_id=7")
assert resp.status_code == 200
assert resp.get_json()[0]["id"] == 5
args, kwargs = mock_api_client.get.call_args
assert args[0] == "/api/statistics/runs"
assert kwargs["params"] == {"recipe_id": "7"}
def test_summary_forwards_production_run_id(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"total": 1}
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"