diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index 6c61b01..564ce77 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -5,7 +5,7 @@ import requests as http_requests from flask import ( Blueprint, Response, abort, flash, jsonify, redirect, render_template, - request, session, url_for, + g, request, session, url_for, ) from flask_babel import gettext as _ @@ -654,6 +654,74 @@ def api_pending_authorisation(): return jsonify(resp), 200 +def _supervisor_credentials(data: dict): + """The capoturno's credentials from the request body (#59). + + Two ways in: ``supervisor_user_id`` + ``supervisor_pin`` (name and 4-digit PIN + from the touch pad) or, as before, ``supervisor_username`` + + ``supervisor_password``. Returns ``(fields, None)`` with the pair to forward, + or ``(None, error_response)`` when neither pair is complete. + """ + pin = str(data.get("supervisor_pin") or "").strip() + user_id = data.get("supervisor_user_id") + if pin and user_id not in (None, ""): + try: + return {"supervisor_user_id": int(user_id), "supervisor_pin": pin}, None + except (TypeError, ValueError): + pass # user_id not a number: falls through to the missing-credentials answer + username = (data.get("supervisor_username") or "").strip() + password = data.get("supervisor_password") or "" + if username and password: + return {"supervisor_username": username, "supervisor_password": password}, None + return None, (jsonify({ + "error": True, "detail": _("Username e password richiesti"), + }), 400) + + +def _post_supervised(endpoint: str, creds: dict, extra: dict | None = None): + """POST an action carrying the capoturno's credentials, forwarding the answer. + + api_client reads every 401 as "your API key is dead" and empties the Flask + session. For these endpoints a 401 can also be the capoturno's wrong PIN or + password, and that must not throw the operator out of their session (#59): + when the detail names the supervisor or the PIN, the session is put back and + the ``session_expired`` flag is dropped so the modal can say "PIN errato". + """ + saved = dict(session) + resp = api_client.post(endpoint, data={**creds, **(extra or {})}) + if isinstance(resp, dict) and resp.get("error"): + detail = str(resp.get("detail") or "").lower() + if resp.get("status_code") == 401 and ("supervisor" in detail or "pin" in detail): + session.update(saved) # the 401 was about the capoturno, not about this session + g.session_expired = False + resp = {k: v for k, v in resp.items() if k != "session_expired"} + if "supervisor_pin" in creds: + resp["detail"] = _("PIN errato") # the backend's English text is not for the operator + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 200 + + +@measure_bp.route("/api/pin-users", methods=["GET"]) +@login_required +@role_required("MeasurementTec") +def api_pin_users(): + """Proxy: names for the capoturno's PIN pad (#59), id and display name only. + + Empty list when the backend does not answer, so the modal falls back to the + password form instead of showing a pad nobody can use. + """ + 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) + ]) + + @measure_bp.route("/api/measurements//authorise", methods=["POST"]) @login_required @role_required("MeasurementTec") @@ -665,20 +733,10 @@ def api_authorise_measurement(measurement_id: int): written down nowhere and nothing depending on it. """ data = request.get_json(silent=True) or {} - username = (data.get("supervisor_username") or "").strip() - password = data.get("supervisor_password") or "" - if not username or not password: - return jsonify({ - "error": True, "detail": _("Username e password richiesti"), - }), 400 - - resp = api_client.post( - f"/api/measurements/{measurement_id}/authorise", - data={"supervisor_username": username, "supervisor_password": password}, - ) - if isinstance(resp, dict) and resp.get("error"): - return jsonify(resp), resp.get("status_code", 500) - return jsonify(resp), 200 + creds, error = _supervisor_credentials(data) # #59: PIN or password + if error: + return error + return _post_supervised(f"/api/measurements/{measurement_id}/authorise", creds) # --------------------------------------------------------------------------- @@ -770,24 +828,12 @@ def _supervised_action(run_id: int, action: str): leave no such record, and is an extra round trip besides. """ data = request.get_json(silent=True) or {} - username = (data.get("supervisor_username") or "").strip() - password = data.get("supervisor_password") or "" - if not username or not password: - return jsonify({ - "error": True, "detail": _("Username e password richiesti"), - }), 400 - - resp = api_client.post( - f"/api/production-runs/{run_id}/{action}", - data={ - "supervisor_username": username, - "supervisor_password": password, - "note": data.get("note"), - }, + creds, error = _supervisor_credentials(data) # #59: PIN or password + if error: + return error + return _post_supervised( + f"/api/production-runs/{run_id}/{action}", creds, {"note": data.get("note")}, ) - if isinstance(resp, dict) and resp.get("error"): - return jsonify(resp), resp.get("status_code", 500) - return jsonify(resp), 200 @measure_bp.route("/api/production//pause", methods=["POST"]) diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index 35d0e0c..75ecca9 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -921,7 +921,55 @@ -
+ {# #59: two ways to authorise - PIN (default, touch pad) or password. #} +
+ + +
+ +
+ {# name grid #} +
+

{{ _('Chi sei?') }}

+
+ +
+
+ {# pad #} +
+

+

{{ _('Inserisci il PIN') }}

+ +
+ + + + +
+
+ +
+ +
{{ _('Rimisura la quota') }}
@@ -1039,6 +1087,11 @@ function taskExecute() { supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione' supervisorUsername: '', supervisorPassword: '', + // #59: the capoturno can authorise with name + 4-digit PIN instead of the password. + supervisorMode: 'pin', // 'pin' | 'password' + supervisorPinUsers: [], // [{id, display_name}] from /measure/api/pin-users + supervisorPinUser: null, + supervisorPin: '', // never kept past the modal: cleared on open and close supervisorError: '', supervisorValidating: false, /* The out-of-tolerance measurement waiting for the capoturno: @@ -1832,6 +1885,7 @@ function taskExecute() { this.supervisorUsername = ''; this.supervisorPassword = ''; this.supervisorError = ''; + this.resetSupervisorPin(); // #59: PIN fields cleared, names reloaded this.showSupervisorModal = true; }, @@ -1840,6 +1894,68 @@ function taskExecute() { this.supervisorUsername = ''; this.supervisorPassword = ''; this.supervisorError = ''; + this.supervisorPin = ''; // #59: the PIN does not outlive the modal + this.supervisorPinUser = null; + }, + + /* #59: PIN mode of the capoturno modal. */ + resetSupervisorPin() { + this.supervisorPin = ''; + this.supervisorPinUser = null; + this.supervisorMode = 'pin'; + this.loadSupervisorPinUsers(); + }, + + // Names with a PIN; an empty list or a failure opens the Password tab instead. + async loadSupervisorPinUsers() { + try { + const resp = await fetch('{{ url_for("measure.api_pin_users") }}', { headers: { 'Accept': 'application/json' } }); + const list = resp.ok ? await resp.json() : []; + this.supervisorPinUsers = Array.isArray(list) ? list : []; + } catch (e) { + this.supervisorPinUsers = []; + } + if (!this.supervisorPinUsers.length) this.supervisorMode = 'password'; + }, + + setSupervisorMode(mode) { + this.supervisorMode = mode; + this.supervisorPin = ''; + this.supervisorPinUser = null; + this.supervisorError = ''; + }, + + selectSupervisorUser(u) { + this.supervisorPinUser = u; + this.supervisorPin = ''; + this.supervisorError = ''; + }, + + backSupervisorUser() { + this.supervisorPinUser = null; + this.supervisorPin = ''; + this.supervisorError = ''; + }, + + // Submits by itself on the fourth digit. + pressSupervisorPin(d) { + if (this.supervisorValidating || this.supervisorPin.length >= 4) return; + this.supervisorPin += d; + this.supervisorError = ''; + if (this.supervisorPin.length === 4) this.submitSupervisor(); + }, + + get supervisorCanSubmit() { + if (this.supervisorMode === 'pin') return !!this.supervisorPinUser && this.supervisorPin.length === 4; + return !!this.supervisorUsername && !!this.supervisorPassword; + }, + + // The pair the proxies forward: id + PIN in PIN mode, username + password otherwise. + supervisorCredentials() { + if (this.supervisorMode === 'pin') { + return { supervisor_user_id: this.supervisorPinUser && this.supervisorPinUser.id, supervisor_pin: this.supervisorPin }; + } + return { supervisor_username: this.supervisorUsername, supervisor_password: this.supervisorPassword }; }, /* What kind of task this is, as declared in the recipe. @@ -1910,16 +2026,15 @@ function taskExecute() { const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, - body: JSON.stringify({ - supervisor_username: this.supervisorUsername, - supervisor_password: this.supervisorPassword, - }), + body: JSON.stringify(this.supervisorCredentials()), // #59: PIN or password }); const data = await resp.json().catch(() => null); if (!resp.ok || (data && data.error)) { this.supervisorError = (data && data.detail) + || (this.supervisorMode === 'pin' && resp.status === 401 ? '{{ _("PIN errato") }}' : '') || '{{ _("Credenziali non valide o utente non autorizzato") }}'; + if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN stays in the modal, pad cleared this.supervisorValidating = false; return; } @@ -1954,10 +2069,7 @@ function taskExecute() { const base = '{{ url_for("measure.api_pause_production", run_id=0) }}' .replace('/0/pause', '/' + this.productionRun.id + '/' + action); - const run = await this.postProduction(base, { - supervisor_username: this.supervisorUsername, - supervisor_password: this.supervisorPassword, - }); + const run = await this.postProduction(base, this.supervisorCredentials()); // #59: PIN or password this.supervisorValidating = false; if (!run) { @@ -1965,6 +2077,7 @@ function taskExecute() { // capoturno sees why the action was refused, rather than a silent no-op. this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}'; this.productionError = ''; + if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN / lockout: pad cleared, modal stays return; } diff --git a/src/frontend/flask_app/tests/test_measure_supervisor_pin.py b/src/frontend/flask_app/tests/test_measure_supervisor_pin.py new file mode 100644 index 0000000..0c64a27 --- /dev/null +++ b/src/frontend/flask_app/tests/test_measure_supervisor_pin.py @@ -0,0 +1,168 @@ +"""Capoturno authorisation by PIN (#59): proxies of the measure blueprint. + +The modal in task_execute.html sends either ``supervisor_user_id`` + ``supervisor_pin`` +or the old ``supervisor_username`` + ``supervisor_password``; the proxies forward +whichever pair is present and let the PIN/lockout refusals through to the modal. +""" +from unittest.mock import patch + +import pytest + +from flask import session + +RUN = {"id": 7, "status": "paused"} + + +@pytest.fixture +def measure_mod(): + from blueprints import measure + return measure + + +def test_pin_users_proxy_returns_only_id_and_name(logged_in_client, measure_mod): + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.get.return_value = [ + {"id": 3, "display_name": "Anna Rossi", "pin_hash": "x"}, + {"id": 4, "display_name": "bruno"}, + ] + resp = logged_in_client.get("/measure/api/pin-users") + assert resp.status_code == 200 + assert resp.get_json() == [ + {"id": 3, "display_name": "Anna Rossi"}, + {"id": 4, "display_name": "bruno"}, + ] + assert mock_api.get.call_args[0][0] == "/api/auth/pin-users" + + +def test_pin_users_proxy_empty_on_backend_error(logged_in_client, measure_mod): + """The modal opens the Password tab when the list is empty.""" + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.get.return_value = {"error": True, "status_code": 502, "detail": "down"} + resp = logged_in_client.get("/measure/api/pin-users") + assert resp.status_code == 200 + assert resp.get_json() == [] + + +@pytest.mark.parametrize("action", ["pause", "resume", "close"]) +def test_supervised_action_forwards_user_id_and_pin(logged_in_client, measure_mod, action): + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.post.return_value = RUN + resp = logged_in_client.post( + f"/measure/api/production/7/{action}", + json={"supervisor_user_id": 5, "supervisor_pin": "1234"}, + ) + assert resp.status_code == 200 + endpoint, kwargs = mock_api.post.call_args + assert endpoint[0] == f"/api/production-runs/7/{action}" + assert kwargs["data"]["supervisor_user_id"] == 5 + assert kwargs["data"]["supervisor_pin"] == "1234" + assert "supervisor_username" not in kwargs["data"] + assert "supervisor_password" not in kwargs["data"] + + +def test_supervised_action_password_pair_unchanged(logged_in_client, measure_mod): + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.post.return_value = RUN + resp = logged_in_client.post( + "/measure/api/production/7/pause", + json={"supervisor_username": "capo", "supervisor_password": "segreta"}, + ) + assert resp.status_code == 200 + kwargs = mock_api.post.call_args[1] + assert kwargs["data"]["supervisor_username"] == "capo" + assert kwargs["data"]["supervisor_password"] == "segreta" + assert "supervisor_pin" not in kwargs["data"] + + +def test_supervised_action_pin_without_user_is_refused(logged_in_client, measure_mod): + with patch.object(measure_mod, "api_client") as mock_api: + resp = logged_in_client.post( + "/measure/api/production/7/pause", json={"supervisor_pin": "1234"}, + ) + assert resp.status_code == 400 + mock_api.post.assert_not_called() + + +def test_authorise_measurement_forwards_user_id_and_pin(logged_in_client, measure_mod): + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.post.return_value = {"id": 9} + resp = logged_in_client.post( + "/measure/api/measurements/9/authorise", + json={"supervisor_user_id": 5, "supervisor_pin": "4321"}, + ) + assert resp.status_code == 200 + endpoint, kwargs = mock_api.post.call_args + assert endpoint[0] == "/api/measurements/9/authorise" + assert kwargs["data"] == {"supervisor_user_id": 5, "supervisor_pin": "4321"} + + +def test_wrong_pin_401_stays_in_the_modal_and_keeps_the_session(logged_in_client, measure_mod): + """api_client empties the session on any 401; a wrong PIN must not log the operator out.""" + def refuse(*args, **kwargs): + session.clear() # what api_client._handle_session_expired does + return { + "error": True, "status_code": 401, "session_expired": True, + "detail": "Invalid supervisor credentials", + } + + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.post.side_effect = refuse + resp = logged_in_client.post( + "/measure/api/production/7/close", + json={"supervisor_user_id": 5, "supervisor_pin": "0000"}, + ) + assert resp.status_code == 401 + body = resp.get_json() + assert body["detail"] == "PIN errato" + assert "session_expired" not in body + with logged_in_client.session_transaction() as sess: + assert sess.get("api_key") == "test-api-key-12345" + + +def test_real_expired_session_401_still_clears_the_session(logged_in_client, measure_mod): + """A 401 that is not about the capoturno keeps its session_expired flag.""" + def expired(*args, **kwargs): + session.clear() + return { + "error": True, "status_code": 401, "session_expired": True, + "detail": "Invalid API key", + } + + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.post.side_effect = expired + resp = logged_in_client.post( + "/measure/api/production/7/close", + json={"supervisor_user_id": 5, "supervisor_pin": "0000"}, + ) + assert resp.status_code == 401 + assert resp.get_json()["session_expired"] is True + with logged_in_client.session_transaction() as sess: + assert "api_key" not in sess + + +def test_locked_user_423_passes_the_detail(logged_in_client, measure_mod): + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.post.return_value = { + "error": True, "status_code": 423, + "detail": "Utente bloccato fino alle 10:15", + } + resp = logged_in_client.post( + "/measure/api/production/7/pause", + json={"supervisor_user_id": 5, "supervisor_pin": "1111"}, + ) + assert resp.status_code == 423 + assert resp.get_json()["detail"] == "Utente bloccato fino alle 10:15" + + +def test_password_401_detail_is_kept(logged_in_client, measure_mod): + """Password mode: the refusal text is the backend's, as before.""" + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.post.return_value = { + "error": True, "status_code": 403, "detail": "User is not authorised as supervisor", + } + resp = logged_in_client.post( + "/measure/api/production/7/pause", + json={"supervisor_username": "op", "supervisor_password": "x"}, + ) + assert resp.status_code == 403 + assert resp.get_json()["detail"] == "User is not authorised as supervisor"