Merge lavoro/daitarn-trafilo-esegui-pin
This commit is contained in:
@@ -5,7 +5,7 @@ import requests as http_requests
|
|||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
||||||
request, session, url_for,
|
g, request, session, url_for,
|
||||||
)
|
)
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
@@ -654,6 +654,74 @@ def api_pending_authorisation():
|
|||||||
return jsonify(resp), 200
|
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/<int:measurement_id>/authorise", methods=["POST"])
|
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@role_required("MeasurementTec")
|
@role_required("MeasurementTec")
|
||||||
@@ -665,20 +733,10 @@ def api_authorise_measurement(measurement_id: int):
|
|||||||
written down nowhere and nothing depending on it.
|
written down nowhere and nothing depending on it.
|
||||||
"""
|
"""
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
username = (data.get("supervisor_username") or "").strip()
|
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
||||||
password = data.get("supervisor_password") or ""
|
if error:
|
||||||
if not username or not password:
|
return error
|
||||||
return jsonify({
|
return _post_supervised(f"/api/measurements/{measurement_id}/authorise", creds)
|
||||||
"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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -770,24 +828,12 @@ def _supervised_action(run_id: int, action: str):
|
|||||||
leave no such record, and is an extra round trip besides.
|
leave no such record, and is an extra round trip besides.
|
||||||
"""
|
"""
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
username = (data.get("supervisor_username") or "").strip()
|
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
||||||
password = data.get("supervisor_password") or ""
|
if error:
|
||||||
if not username or not password:
|
return error
|
||||||
return jsonify({
|
return _post_supervised(
|
||||||
"error": True, "detail": _("Username e password richiesti"),
|
f"/api/production-runs/{run_id}/{action}", creds, {"note": data.get("note")},
|
||||||
}), 400
|
|
||||||
|
|
||||||
resp = api_client.post(
|
|
||||||
f"/api/production-runs/{run_id}/{action}",
|
|
||||||
data={
|
|
||||||
"supervisor_username": username,
|
|
||||||
"supervisor_password": password,
|
|
||||||
"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/<int:run_id>/pause", methods=["POST"])
|
@measure_bp.route("/api/production/<int:run_id>/pause", methods=["POST"])
|
||||||
|
|||||||
@@ -921,7 +921,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 mb-5">
|
{# #59: two ways to authorise - PIN (default, touch pad) or password. #}
|
||||||
|
<div class="flex gap-2 mb-4" role="tablist">
|
||||||
|
<button type="button" role="tab" @click="setSupervisorMode('pin')"
|
||||||
|
:disabled="!supervisorPinUsers.length"
|
||||||
|
class="btn flex-1 text-sm"
|
||||||
|
:class="supervisorMode === 'pin' ? 'btn-primary' : 'btn-secondary'">{{ _('PIN') }}</button>
|
||||||
|
<button type="button" role="tab" @click="setSupervisorMode('password')"
|
||||||
|
class="btn flex-1 text-sm"
|
||||||
|
:class="supervisorMode === 'password' ? 'btn-primary' : 'btn-secondary'">{{ _('Password') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="supervisorMode === 'pin'" class="mb-5">
|
||||||
|
{# name grid #}
|
||||||
|
<div x-show="!supervisorPinUser">
|
||||||
|
<p class="text-xs text-center text-[var(--text-secondary)] mb-3">{{ _('Chi sei?') }}</p>
|
||||||
|
<div class="grid grid-cols-2 gap-3 max-h-64 overflow-y-auto">
|
||||||
|
<template x-for="u in supervisorPinUsers" :key="u.id">
|
||||||
|
<button type="button" @click="selectSupervisorUser(u)"
|
||||||
|
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{# pad #}
|
||||||
|
<div x-show="supervisorPinUser">
|
||||||
|
<p class="text-center font-semibold mb-1" x-text="supervisorPinUser && supervisorPinUser.display_name"></p>
|
||||||
|
<p class="text-xs 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="supervisorPin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<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="pressSupervisorPin(d)" :disabled="supervisorValidating"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
|
||||||
|
</template>
|
||||||
|
<button type="button" @click="backSupervisorUser()" :disabled="supervisorValidating"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
|
||||||
|
<button type="button" @click="pressSupervisorPin('0')" :disabled="supervisorValidating"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
|
||||||
|
<button type="button" @click="supervisorPin = supervisorPin.slice(0, -1)" :disabled="supervisorValidating"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p x-show="supervisorError" class="text-sm text-center text-red-600 mt-3" role="alert" x-text="supervisorError"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="supervisorMode === 'password'" class="space-y-3 mb-5">
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
||||||
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
|
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
|
||||||
@@ -945,13 +993,13 @@
|
|||||||
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
|
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="submitSupervisor()"
|
<button @click="submitSupervisor()"
|
||||||
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
|
:disabled="!supervisorCanSubmit || supervisorValidating"
|
||||||
class="btn btn-primary flex-1 text-sm gap-1">
|
class="btn btn-primary flex-1 text-sm gap-1">
|
||||||
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ _('Autorizza') }}
|
<span x-text="supervisorMode === 'pin' ? '{{ _('Conferma') }}' : '{{ _('Autorizza') }}'"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1039,6 +1087,11 @@ function taskExecute() {
|
|||||||
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
||||||
supervisorUsername: '',
|
supervisorUsername: '',
|
||||||
supervisorPassword: '',
|
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: '',
|
supervisorError: '',
|
||||||
supervisorValidating: false,
|
supervisorValidating: false,
|
||||||
/* The out-of-tolerance measurement waiting for the capoturno:
|
/* The out-of-tolerance measurement waiting for the capoturno:
|
||||||
@@ -1832,6 +1885,7 @@ function taskExecute() {
|
|||||||
this.supervisorUsername = '';
|
this.supervisorUsername = '';
|
||||||
this.supervisorPassword = '';
|
this.supervisorPassword = '';
|
||||||
this.supervisorError = '';
|
this.supervisorError = '';
|
||||||
|
this.resetSupervisorPin(); // #59: PIN fields cleared, names reloaded
|
||||||
this.showSupervisorModal = true;
|
this.showSupervisorModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1840,6 +1894,68 @@ function taskExecute() {
|
|||||||
this.supervisorUsername = '';
|
this.supervisorUsername = '';
|
||||||
this.supervisorPassword = '';
|
this.supervisorPassword = '';
|
||||||
this.supervisorError = '';
|
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.
|
/* What kind of task this is, as declared in the recipe.
|
||||||
@@ -1910,16 +2026,15 @@ function taskExecute() {
|
|||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(this.supervisorCredentials()), // #59: PIN or password
|
||||||
supervisor_username: this.supervisorUsername,
|
|
||||||
supervisor_password: this.supervisorPassword,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await resp.json().catch(() => null);
|
const data = await resp.json().catch(() => null);
|
||||||
|
|
||||||
if (!resp.ok || (data && data.error)) {
|
if (!resp.ok || (data && data.error)) {
|
||||||
this.supervisorError = (data && data.detail)
|
this.supervisorError = (data && data.detail)
|
||||||
|
|| (this.supervisorMode === 'pin' && resp.status === 401 ? '{{ _("PIN errato") }}' : '')
|
||||||
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
|| '{{ _("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;
|
this.supervisorValidating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1954,10 +2069,7 @@ function taskExecute() {
|
|||||||
|
|
||||||
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
|
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
|
||||||
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
|
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
|
||||||
const run = await this.postProduction(base, {
|
const run = await this.postProduction(base, this.supervisorCredentials()); // #59: PIN or password
|
||||||
supervisor_username: this.supervisorUsername,
|
|
||||||
supervisor_password: this.supervisorPassword,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.supervisorValidating = false;
|
this.supervisorValidating = false;
|
||||||
if (!run) {
|
if (!run) {
|
||||||
@@ -1965,6 +2077,7 @@ function taskExecute() {
|
|||||||
// capoturno sees why the action was refused, rather than a silent no-op.
|
// capoturno sees why the action was refused, rather than a silent no-op.
|
||||||
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
|
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
|
||||||
this.productionError = '';
|
this.productionError = '';
|
||||||
|
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN / lockout: pad cleared, modal stays
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user