"""MeasurementTec blueprint - recipe selection and measurement execution.""" import urllib.parse from flask import ( Blueprint, abort, flash, jsonify, redirect, render_template, request, session, url_for, ) from flask_babel import gettext as _ from blueprints.auth import login_required, role_required from config import Config from services.api_client import api_client from services.file_proxy import proxy_file measure_bp = Blueprint("measure", __name__) # Session key holding a station chosen at runtime, overriding Config.STATION_CODE. STATION_OVERRIDE_KEY = "station_override" def _current_station() -> tuple[str | None, bool]: """Return the station this client acts as, and whether it is an override.""" override = session.get(STATION_OVERRIDE_KEY) if override: return override, True return Config.STATION_CODE, False def _apply_station_switch() -> None: """Honour ?station=CODE, used to exercise several stations from one PC. Only active when STATION_SWITCH_ENABLED is set: in production the station identity comes from the local install, and silently measuring against another station's recipes would break traceability. An empty value clears the override and falls back to the configured station. The requested code is validated against the server before being stored, so a typo leaves the operator on the current station with an explanation rather than stranded on a station that does not exist. """ if "station" not in request.args: return requested = (request.args.get("station") or "").strip().upper() if not Config.STATION_SWITCH_ENABLED: flash(_("Il cambio stazione da URL non è abilitato su questa postazione."), "error") return if not requested: if session.pop(STATION_OVERRIDE_KEY, None): flash(_("Stazione riportata a quella configurata."), "info") return try: probe = api_client.get_station_recipes(requested) except Exception: flash(_("Impossibile contattare il server per cambiare stazione."), "error") return if isinstance(probe, dict) and probe.get("error"): flash( _("Stazione '%(code)s' inesistente o disattivata: stazione invariata.", code=requested), "error", ) return session[STATION_OVERRIDE_KEY] = requested flash(_("Stazione corrente: %(code)s", code=requested), "info") # --------------------------------------------------------------------------- # Route: Recipe selection # --------------------------------------------------------------------------- @measure_bp.route("/select") @login_required @role_required("MeasurementTec") def select_recipe(): """Recipe selection page with search and barcode support.""" _apply_station_switch() station_code, station_overridden = _current_station() # Fail-fast if no station is configured and none was chosen if not station_code: return render_template("errors/station_not_configured.html"), 503 # Load recipes filtered by station try: resp = api_client.get_station_recipes(station_code) except Exception as e: return render_template( "errors/station_not_configured.html", error=str(e), ), 502 if isinstance(resp, dict) and resp.get("error"): flash( _("Errore nel caricamento delle ricette: %(detail)s", detail=resp.get("detail", "")), "error", ) recipes = [] else: # API may return paginated envelope or plain list recipes = resp.get("items", resp) if isinstance(resp, dict) else resp # Auto-fill from query params auto_recipe_code = request.args.get("recipe", "") auto_lot = request.args.get("lot", session.get("lot_number", "")) auto_serial = request.args.get("serial", session.get("serial_number", "")) return render_template( "measure/select_recipe.html", recipes=recipes, station_code=station_code, station_overridden=station_overridden, station_configured=Config.STATION_CODE, auto_recipe_code=auto_recipe_code, auto_lot=auto_lot, auto_serial=auto_serial, ) def _remember_traceability() -> tuple[str, str]: """Carry lot and serial from the query string into the session.""" lot_number = request.args.get("lot_number", session.get("lot_number", "")) serial_number = request.args.get( "serial_number", session.get("serial_number", ""), ) if lot_number: session["lot_number"] = lot_number if serial_number: session["serial_number"] = serial_number return lot_number, serial_number def _missing_traceability(recipe: dict, lot: str, serial: str) -> list[str]: """The compulsory fields this recipe is still owed.""" missing = [] if recipe.get("requires_lot") and not (lot or "").strip(): missing.append(_("lotto")) if recipe.get("requires_serial") and not (serial or "").strip(): missing.append(_("seriale")) return missing def _ordered_tasks(recipe_id: int) -> list[dict] | None: """Tasks of a recipe's current version in execution order, or None on error.""" resp = api_client.get(f"/api/recipes/{recipe_id}/tasks") if isinstance(resp, dict) and resp.get("error"): return None tasks = resp if isinstance(resp, list) else resp.get("items", []) return sorted(tasks, key=lambda t: (t.get("order_index", 0), t.get("id", 0))) def _task_progress(version_id, run_id) -> dict[int, dict]: """Quotes already taken per measurement task, keyed by task id. Never fatal: a list that cannot say which task was left half done is still a usable list, and losing the whole page over a badge would be the worse trade. """ if not version_id: return {} params = {"version_id": version_id} if run_id: params["production_run_id"] = run_id resp = api_client.get("/api/measurements/task-progress", params=params) if not isinstance(resp, dict) or resp.get("error"): return {} return {row["task_id"]: row for row in resp.get("tasks", [])} def _current_run_id() -> int | None: """The production open at this station, if there is one.""" station_code, _overridden = _current_station() if not station_code: return None # The endpoint answers with the run itself, or null when nothing is running. resp = api_client.get( "/api/production-runs/current", params={"station_code": station_code}, ) if not isinstance(resp, dict) or resp.get("error"): return None return resp.get("id") def _recipe_allowed_at_station(recipe_id: int) -> bool: """Is this recipe among the ones the picker shows for the current station? (#24) The picker (/measure/select) already filters through get_station_recipes(); barcode lookup, the guided-sequence shortcut and a direct link into task_execute all bypass that page, so without this check an operator could measure a recipe never assigned to this station just by knowing (or scanning) its code. Same source of truth, same call - no separate policy to keep in sync. Anything we cannot confirm (no station configured, the call failing) is refused, not let through. """ station_code, _overridden = _current_station() if not station_code: return False try: resp = api_client.get_station_recipes(station_code) except Exception: return False if isinstance(resp, dict) and resp.get("error"): return False recipes = resp.get("items", resp) if isinstance(resp, dict) else resp if not isinstance(recipes, list): return False return any(isinstance(r, dict) and r.get("id") == recipe_id for r in recipes) # --------------------------------------------------------------------------- # Route: start the guided sequence # --------------------------------------------------------------------------- @measure_bp.route("/start/") @login_required @role_required("MeasurementTec") def start_sequence(recipe_id: int): """Open the first task of a recipe and let the operator walk the sequence. Choosing a recipe used to land on the full task list, which asked the operator to pick a starting point before they had done anything. The sequence is the normal way through; the list is still there, one level down, for when they need to see the whole of it. """ lot_number, serial_number = _remember_traceability() recipe_resp = api_client.get(f"/api/recipes/{recipe_id}") if recipe_resp.get("error"): flash( _("Ricetta non trovata: %(detail)s", detail=recipe_resp.get("detail", "")), "error", ) return redirect(url_for("measure.select_recipe")) if not _recipe_allowed_at_station(recipe_id): abort(403, description=_("Ricetta non assegnata a questa stazione")) missing = _missing_traceability(recipe_resp, lot_number, serial_number) if missing: flash( _("Compila prima: %(fields)s", fields=", ".join(missing)), "error", ) return redirect(url_for( "measure.select_recipe", recipe=recipe_resp.get("code", ""), )) tasks = _ordered_tasks(recipe_id) if not tasks: flash(_("Questa ricetta non ha ancora task definiti."), "error") return redirect(url_for("measure.task_list", recipe_id=recipe_id)) return redirect(url_for("measure.task_execute", task_id=tasks[0]["id"])) # --------------------------------------------------------------------------- # Route: Task list for a recipe # --------------------------------------------------------------------------- @measure_bp.route("/tasks/") @login_required @role_required("MeasurementTec") def task_list(recipe_id: int): """Task list for selected recipe.""" lot_number, serial_number = _remember_traceability() # Load recipe details recipe_resp = api_client.get(f"/api/recipes/{recipe_id}") if recipe_resp.get("error"): flash( _("Ricetta non trovata: %(detail)s", detail=recipe_resp.get("detail", "")), "error", ) return redirect(url_for("measure.select_recipe")) tasks = _ordered_tasks(recipe_id) if tasks is None: flash(_("Errore nel caricamento dei task."), "error") tasks = [] version = recipe_resp.get("current_version") or {} progress = _task_progress(version.get("id"), _current_run_id()) return render_template( "measure/task_list.html", recipe=recipe_resp, tasks=tasks, progress=progress, lot_number=lot_number, serial_number=serial_number, ) # --------------------------------------------------------------------------- # Route: Task execution (measurement input) # --------------------------------------------------------------------------- @measure_bp.route("/execute/") @login_required @role_required("MeasurementTec") def task_execute(task_id: int): """Execute measurements for a task.""" # Load task + subtasks task_resp = api_client.get(f"/api/tasks/{task_id}") if task_resp.get("error"): flash( _("Task non trovato: %(detail)s", detail=task_resp.get("detail", "")), "error", ) return redirect(url_for("measure.select_recipe")) recipe_id = task_resp.get("recipe_id") if recipe_id and not _recipe_allowed_at_station(recipe_id): abort(403, description=_("Ricetta non assegnata a questa stazione")) lot_number = session.get("lot_number", "") serial_number = session.get("serial_number", "") # Load all task IDs for this recipe (ordered) for auto-advance all_task_ids = [] measurement_interval_minutes = None # Allowed until the recipe says otherwise: a recipe that could not be read must # not silently take the keypad away from the operator. allow_manual_input = True if recipe_id: tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks") if isinstance(tasks_resp, list): sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0)) all_task_ids = [t["id"] for t in sorted_tasks] recipe_resp = api_client.get(f"/api/recipes/{recipe_id}") if not recipe_resp.get("error"): measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes") allow_manual_input = bool(recipe_resp.get("allow_manual_input", True)) return render_template( "measure/task_execute.html", task=task_resp, lot_number=lot_number, serial_number=serial_number, all_task_ids=all_task_ids, measurement_interval_minutes=measurement_interval_minutes, allow_manual_input=allow_manual_input, ) # --------------------------------------------------------------------------- # Route: Task completion summary # --------------------------------------------------------------------------- @measure_bp.route("/complete/") @login_required @role_required("MeasurementTec") def task_complete(recipe_id: int): """Task completion summary with all measurements.""" # Retrieve version_id from query params version_id = request.args.get("version_id") lot_number = session.get("lot_number", "") serial_number = session.get("serial_number", "") # Load recipe for context recipe_resp = api_client.get(f"/api/recipes/{recipe_id}") if recipe_resp.get("error"): flash( _("Ricetta non trovata: %(detail)s", detail=recipe_resp.get("detail", "")), "error", ) return redirect(url_for("measure.select_recipe")) # Load tasks+subtasks for this recipe to build subtask and task lookup tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks") subtask_map = {} subtask_task_map = {} # subtask_id → task info if isinstance(tasks_resp, list): for task in tasks_resp: for st in task.get("subtasks", []): subtask_map[st["id"]] = st subtask_task_map[st["id"]] = { "id": task["id"], "title": task.get("title", ""), "order_index": task.get("order_index", 0), } # Load measurements if version_id provided measurements = [] if version_id: # Scoped to this measurement session, not the whole version (#30): the # active production run when known, else lot/serial + current operator. params = {"version_id": version_id, "per_page": 500} run_id = _current_run_id() if run_id: params["production_run_id"] = run_id if lot_number: params["lot_number"] = lot_number if serial_number: params["serial_number"] = serial_number user_id = session.get("user_id") if user_id: params["measured_by"] = user_id raw = [] page = 1 while True: meas_resp = api_client.get( "/api/measurements", params={**params, "page": page}, ) if isinstance(meas_resp, dict) and meas_resp.get("error"): break page_items = ( meas_resp if isinstance(meas_resp, list) else meas_resp.get("items", []) ) raw.extend(page_items) total_pages = meas_resp.get("pages", 1) if isinstance(meas_resp, dict) else 1 if page >= total_pages or not page_items: break page += 1 # Enrich each measurement with nested subtask data for m in raw: st = subtask_map.get(m.get("subtask_id"), {}) m["subtask"] = st m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {}) # Compute deviation if not present if m.get("deviation") is None and st.get("nominal") is not None: try: m["deviation"] = m["value"] - st["nominal"] except (TypeError, KeyError): m["deviation"] = 0.0 measurements = raw return render_template( "measure/task_complete.html", recipe=recipe_resp, measurements=measurements, lot_number=lot_number, serial_number=serial_number, ) # --------------------------------------------------------------------------- # Route: Barcode lookup (AJAX) # --------------------------------------------------------------------------- @measure_bp.route("/lookup-barcode", methods=["POST"]) @login_required @role_required("MeasurementTec") def lookup_barcode(): """Look up a recipe by barcode/code. Returns JSON for AJAX calls.""" data = request.get_json(silent=True) or {} code = data.get("code", "") # #37.4: a non-string "code" (e.g. {"code": 5}) crashed .strip() into a # 500. A slash in the code (or "../") used to walk straight into the URL # path unescaped - "AB/123" split into segments the backend never # matched, and "../" could reach another GET endpoint with this user's # permissions. if not isinstance(code, str): return jsonify({"error": True, "detail": _("Codice non valido")}), 400 code = code.strip() if not code: return jsonify({"error": True, "detail": _("Codice non fornito")}), 400 resp = api_client.get(f"/api/recipes/code/{urllib.parse.quote(code, safe='')}") if resp.get("error"): return jsonify({ "error": True, "detail": resp.get("detail", _("Ricetta non trovata")), }), 404 if not _recipe_allowed_at_station(resp.get("id")): return jsonify({ "error": True, "detail": _("Ricetta non assegnata a questa stazione"), }), 403 return jsonify(resp) # --------------------------------------------------------------------------- # Route: Save lot/serial to session (AJAX) # --------------------------------------------------------------------------- @measure_bp.route("/save-traceability", methods=["POST"]) @login_required @role_required("MeasurementTec") def save_traceability(): """Save lot_number and serial_number to session.""" data = request.get_json(silent=True) or {} lot = data.get("lot_number", "").strip() serial = data.get("serial_number", "").strip() if lot: session["lot_number"] = lot if serial: session["serial_number"] = serial return jsonify({"ok": True}) # --------------------------------------------------------------------------- # Route: Save measurement (AJAX proxy to FastAPI) # --------------------------------------------------------------------------- @measure_bp.route("/save-measurement", methods=["POST"]) @login_required @role_required("MeasurementTec") def save_measurement(): """Save a single measurement value via API proxy. Expects JSON body: subtask_id: int task_id: int value: float pass_fail: str ('pass' | 'warning' | 'fail') deviation: float lot_number: str (optional) serial_number: str (optional) Returns JSON with the created measurement or error. """ data = request.get_json(silent=True) or {} # Validate required fields subtask_id = data.get("subtask_id") version_id = data.get("version_id") value = data.get("value") if subtask_id is None or version_id is None or value is None: return jsonify({ "error": True, "detail": _("Dati mancanti: subtask_id, version_id e value sono obbligatori"), }), 400 # Build payload for the FastAPI backend payload = { "subtask_id": subtask_id, "version_id": version_id, "value": value, "lot_number": data.get("lot_number", session.get("lot_number", "")), "serial_number": data.get("serial_number", session.get("serial_number", "")), "input_method": data.get("input_method", "manual"), "input_duration_ms": data.get("input_duration_ms"), # Attaches the measurement to the production under way, so fine produzione # can hand the whole run to the statistics file. "production_run_id": data.get("production_run_id"), } # Trailing slash matters: the route is declared as "/", so omitting it costs # a 307 redirect on every single measurement saved. resp = api_client.post("/api/measurements/", data=payload) if resp.get("error"): status_code = resp.get("status_code", 500) error_body = { "error": True, "detail": resp.get("detail", _("Errore nel salvataggio")), } if resp.get("session_expired"): error_body["session_expired"] = True return jsonify(error_body), status_code if status_code >= 400 else 500 return jsonify(resp), 201 # --------------------------------------------------------------------------- # Route: task progress (#37.8) - browser cannot call the backend directly # (X-API-Key), same as every other /api/* proxy in this file. # --------------------------------------------------------------------------- @measure_bp.route("/api/task-progress/", methods=["GET"]) @login_required @role_required("MeasurementTec") def api_task_progress(task_id: int): """Proxy: how many quotes of this task are already measured. Reuses _task_progress() - same version_id/production_run_id scoping as task_list.html's badges - so this answers with exactly the counts (quotes/measured/state) the backend can give without conflating one cycle with another; it does not, and cannot, say which quotes or with what values (task-progress is an aggregate, not a per-subtask list). """ task_resp = api_client.get(f"/api/tasks/{task_id}") if isinstance(task_resp, dict) and task_resp.get("error"): return jsonify(task_resp), task_resp.get("status_code", 500) recipe_id = task_resp.get("recipe_id") if recipe_id and not _recipe_allowed_at_station(recipe_id): abort(403, description=_("Ricetta non assegnata a questa stazione")) version_id = task_resp.get("version_id") progress = _task_progress(version_id, _current_run_id()) row = progress.get(task_id, { "task_id": task_id, "quotes": 0, "measured": 0, "state": "empty", }) return jsonify(row), 200 # --------------------------------------------------------------------------- # Route: quotes already measured in the current cycle (#40) # --------------------------------------------------------------------------- @measure_bp.route("/api/current-cycle/", methods=["GET"]) @login_required @role_required("MeasurementTec") def api_current_cycle(task_id: int): """Proxy: quotes of this task already measured in the cycle under way. The run comes from _current_run_id(), like the rest of the page. Outside a production there is no cycle to restore and the answer is an empty list. """ task_resp = api_client.get(f"/api/tasks/{task_id}") if isinstance(task_resp, dict) and task_resp.get("error"): return jsonify(task_resp), task_resp.get("status_code", 500) recipe_id = task_resp.get("recipe_id") if recipe_id and not _recipe_allowed_at_station(recipe_id): abort(403, description=_("Ricetta non assegnata a questa stazione")) run_id = _current_run_id() if not run_id: return jsonify({"measurements": []}), 200 resp = api_client.get( "/api/measurements/current-cycle", params={"production_run_id": run_id, "task_id": task_id}, ) if not isinstance(resp, dict) or resp.get("error"): # Never fatal: without the restore the screen behaves as it always did. return jsonify({"measurements": []}), 200 return jsonify(resp), 200 # --------------------------------------------------------------------------- # Routes: Out-of-tolerance authorisation (point 5) # --------------------------------------------------------------------------- @measure_bp.route("/api/measurements/pending-authorisation", methods=["GET"]) @login_required @role_required("MeasurementTec") def api_pending_authorisation(): """Proxy: the out-of-tolerance measurement holding this operator up, or null. Asked when the measurement screen loads, so reloading the page finds the same block the server enforces rather than a clean slate. """ params = {"version_id": request.args.get("version_id")} run_id = request.args.get("production_run_id") if run_id: params["production_run_id"] = run_id resp = api_client.get("/api/measurements/pending-authorisation", params=params) if isinstance(resp, dict) and resp.get("error"): return jsonify(resp), resp.get("status_code", 500) return jsonify(resp), 200 @measure_bp.route("/api/measurements//authorise", methods=["POST"]) @login_required @role_required("MeasurementTec") def api_authorise_measurement(measurement_id: int): """Proxy: the capoturno lets an out-of-tolerance value stand. The credentials go to the endpoint that records the approval on the measurement. Checking them separately, as this used to, left the approval 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 # --------------------------------------------------------------------------- # Routes: Production run (state that must outlive the page) # --------------------------------------------------------------------------- @measure_bp.route("/api/production/current", methods=["GET"]) @login_required @role_required("MeasurementTec") def api_current_production(): """Proxy: the production open at this station, or null. Every page asks this on load. Before, the timer and the cycle count lived in the Alpine component and a change of task - a full page load - wiped them. """ station_code, _overridden = _current_station() if not station_code: return jsonify({"error": True, "detail": _("Stazione non configurata")}), 503 resp = api_client.get( "/api/production-runs/current", params={"station_code": station_code}, ) 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/start", methods=["POST"]) @login_required @role_required("MeasurementTec") def api_start_production(): """Proxy: open a production at this station.""" station_code, _overridden = _current_station() if not station_code: return jsonify({"error": True, "detail": _("Stazione non configurata")}), 503 data = request.get_json(silent=True) or {} payload = { "station_code": station_code, "recipe_id": data.get("recipe_id"), "version_id": data.get("version_id"), "lot_number": data.get("lot_number") or session.get("lot_number") or None, "serial_number": data.get("serial_number") or session.get("serial_number") or None, } resp = api_client.post("/api/production-runs", data=payload) if isinstance(resp, dict) and resp.get("error"): return jsonify(resp), resp.get("status_code", 500) return jsonify(resp), 201 @measure_bp.route("/api/production//cycle", methods=["POST"]) @login_required @role_required("MeasurementTec") def api_complete_cycle(run_id: int): """Proxy: record a finished measurement task. The task travels with the call: only the last measurement task of the recipe closes the cycle and restarts the interval, and the server is what decides. """ data = request.get_json(silent=True) or {} resp = api_client.post( f"/api/production-runs/{run_id}/cycle", data={"task_id": data.get("task_id"), "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//remeasure", methods=["POST"]) @login_required @role_required("MeasurementTec") def api_remeasure(run_id: int): """Proxy: the piece was turned over - measure again inside the same cycle.""" data = request.get_json(silent=True) or {} resp = api_client.post( f"/api/production-runs/{run_id}/remeasure", data={"task_id": data.get("task_id"), "note": data.get("note")}, ) if isinstance(resp, dict) and resp.get("error"): return jsonify(resp), resp.get("status_code", 500) return jsonify(resp), 200 def _supervised_action(run_id: int, action: str): """Forward an action that needs the capoturno's authorisation. The credentials go straight to the API, which checks them and records who authorised what on the run's trace. Validating them separately first would 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"), }, ) 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"]) @login_required @role_required("MeasurementTec") def api_pause_production(run_id: int): """Proxy: fermo linea - suspends the run and freezes the countdown.""" return _supervised_action(run_id, "pause") @measure_bp.route("/api/production//resume", methods=["POST"]) @login_required @role_required("MeasurementTec") def api_resume_production(run_id: int): """Proxy: restart a stopped line, giving back the time the stop took.""" return _supervised_action(run_id, "resume") @measure_bp.route("/api/production//close", methods=["POST"]) @login_required @role_required("MeasurementTec") def api_close_production(run_id: int): """Proxy: fine produzione - closes the run and emits the statistics file.""" return _supervised_action(run_id, "close") # --------------------------------------------------------------------------- # Route: File proxy (browser can't send X-API-Key directly) # --------------------------------------------------------------------------- @measure_bp.route("/api/files/", methods=["GET"]) @login_required def api_get_file(file_path: str): """Proxy: Serve file from API server (browser can't send X-API-Key).""" return proxy_file(file_path)