192d4b590c
Route POST /measure/api/vision/grab (proxy verso il servizio camera), get_station_by_code, pulsante «Acquisisci dalla camera», grab() in vision_execute.js, campi camera_url e camera_source nell'admin stazioni, traduzioni it/en, extra_hosts host.docker.internal nel compose di sviluppo. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1079 lines
41 KiB
Python
1079 lines
41 KiB
Python
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
|
import urllib.parse
|
|
|
|
import requests as http_requests
|
|
|
|
from flask import (
|
|
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
|
g, 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/<int:recipe_id>")
|
|
@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/<int:recipe_id>")
|
|
@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/<int:task_id>")
|
|
@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,
|
|
camera_enabled=_station_camera(_current_station()[0]) is not None,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Route: Task completion summary
|
|
# ---------------------------------------------------------------------------
|
|
@measure_bp.route("/complete/<int:recipe_id>")
|
|
@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/<int:task_id>", 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/<int:task_id>", 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
|
|
|
|
|
|
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"])
|
|
@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 {}
|
|
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
|
if error:
|
|
return error
|
|
return _post_supervised(f"/api/measurements/{measurement_id}/authorise", creds)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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/<int:run_id>/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/<int:run_id>/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 {}
|
|
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")},
|
|
)
|
|
|
|
|
|
@measure_bp.route("/api/production/<int:run_id>/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/<int:run_id>/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/<int:run_id>/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")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes: task results (#53 #54 #55) - outcome of a "check" task and the values /
|
|
# attachments of a "note" task with fields. The browser cannot send X-API-Key,
|
|
# so each call goes through here.
|
|
# ---------------------------------------------------------------------------
|
|
def _relay(resp, ok_status: int = 200):
|
|
"""JSON back to the browser: backend errors keep their status and detail.
|
|
|
|
The detail is what the operator reads («Per un NOK serve la nota»), so it is
|
|
passed through as it came, never replaced by a generic message.
|
|
"""
|
|
if isinstance(resp, dict) and resp.get("error"):
|
|
body = {"error": True, "detail": resp.get("detail", _("Errore nel salvataggio"))}
|
|
if resp.get("session_expired"):
|
|
body["session_expired"] = True
|
|
status_code = resp.get("status_code", 500)
|
|
return jsonify(body), status_code if status_code >= 400 else 500
|
|
return jsonify(resp), ok_status
|
|
|
|
|
|
@measure_bp.route("/api/tasks/<int:task_id>", methods=["GET"])
|
|
@login_required
|
|
@role_required("MeasurementTec")
|
|
def api_get_task(task_id: int):
|
|
"""Proxy: a task with its fields (#55: the tasks to fill at the end of production)."""
|
|
return _relay(api_client.get(f"/api/tasks/{task_id}"))
|
|
|
|
|
|
@measure_bp.route("/api/task-results", methods=["POST"])
|
|
@login_required
|
|
@role_required("MeasurementTec")
|
|
def api_save_task_result():
|
|
"""Proxy: record the result of a check / note task (#54, #55)."""
|
|
data = request.get_json(silent=True) or {}
|
|
if data.get("task_id") is None:
|
|
return jsonify({
|
|
"error": True, "detail": _("Dati mancanti: task_id è obbligatorio"),
|
|
}), 400
|
|
payload = {
|
|
"task_id": data.get("task_id"),
|
|
"production_run_id": data.get("production_run_id"),
|
|
"outcome": data.get("outcome"),
|
|
"note": data.get("note"),
|
|
"values": data.get("values"),
|
|
"attachments": data.get("attachments"),
|
|
}
|
|
return _relay(api_client.post("/api/task-results", data=payload), 201)
|
|
|
|
|
|
@measure_bp.route("/api/task-results", methods=["GET"])
|
|
@login_required
|
|
@role_required("MeasurementTec")
|
|
def api_list_task_results():
|
|
"""Proxy: results already recorded, filtered by production run and/or task."""
|
|
params = {
|
|
key: request.args[key]
|
|
for key in ("production_run_id", "task_id")
|
|
if request.args.get(key)
|
|
}
|
|
return _relay(api_client.get("/api/task-results", params=params))
|
|
|
|
|
|
@measure_bp.route("/api/attachments", methods=["POST"])
|
|
@login_required
|
|
@role_required("MeasurementTec")
|
|
def api_upload_attachment():
|
|
"""Proxy: upload an attachment of a note task (image or PDF), multipart.
|
|
|
|
Answers with the backend's reply: the `file_path` in it is what goes into the
|
|
`attachments` of the result.
|
|
"""
|
|
upload = request.files.get("file")
|
|
if upload is None or not upload.filename:
|
|
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
|
|
resp = api_client.post(
|
|
"/api/task-results/attachments", # #55: upload per l operatore (files/upload e solo Maker)
|
|
files={"file": (
|
|
upload.filename,
|
|
upload.read(),
|
|
upload.mimetype or "application/octet-stream",
|
|
)},
|
|
)
|
|
return _relay(resp, 201)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Route: File proxy (browser can't send X-API-Key directly)
|
|
# ---------------------------------------------------------------------------
|
|
@measure_bp.route("/api/files/<path:file_path>", 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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Route: vision execute (#46) - the browser sends an image, we relay it to
|
|
# POST /api/vision/execute (multipart) because it cannot send X-API-Key itself.
|
|
# ---------------------------------------------------------------------------
|
|
@measure_bp.route("/api/vision/execute", methods=["POST"])
|
|
@login_required
|
|
@role_required("MeasurementTec")
|
|
def api_vision_execute():
|
|
"""Proxy: run the task's VisionSuite recipe on the uploaded image."""
|
|
image = request.files.get("image")
|
|
task_id = request.form.get("task_id")
|
|
if image is None or not task_id:
|
|
return jsonify({
|
|
"error": True,
|
|
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
|
|
}), 400
|
|
|
|
# Same context fields as the manual flow; empty ones are left out so the
|
|
# backend sees them as absent rather than as "".
|
|
form = {"task_id": task_id}
|
|
for key in ("task_type", "lot_number", "serial_number", "production_run_id"):
|
|
val = request.form.get(key)
|
|
if val:
|
|
form[key] = val
|
|
form.setdefault("lot_number", session.get("lot_number", ""))
|
|
form.setdefault("serial_number", session.get("serial_number", ""))
|
|
|
|
resp = api_client.post(
|
|
"/api/vision/execute",
|
|
data=form,
|
|
files={"image": (
|
|
image.filename or "capture.jpg",
|
|
image.read(),
|
|
image.mimetype or "application/octet-stream",
|
|
)},
|
|
)
|
|
if resp.get("error"):
|
|
status_code = resp.get("status_code", 500)
|
|
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
|
|
if resp.get("session_expired"):
|
|
body["session_expired"] = True
|
|
return jsonify(body), status_code if status_code >= 400 else 500
|
|
return jsonify(resp), 200
|
|
|
|
|
|
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
|
"""(camera_url, camera_source) of the station, None when it has no camera.
|
|
|
|
A station that cannot be read counts as "no camera": the grab button simply
|
|
does not show, the file input keeps working.
|
|
"""
|
|
if not station_code:
|
|
return None
|
|
station = api_client.get_station_by_code(station_code)
|
|
if not isinstance(station, dict) or station.get("error"):
|
|
return None
|
|
url = station.get("camera_url")
|
|
source = station.get("camera_source")
|
|
if not url or not source:
|
|
return None
|
|
return str(url), str(source)
|
|
|
|
|
|
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
|
|
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
|
|
@measure_bp.route("/api/vision/grab", methods=["POST"])
|
|
@login_required
|
|
@role_required("MeasurementTec")
|
|
def api_vision_grab():
|
|
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
|
|
camera = _station_camera(_current_station()[0])
|
|
if camera is None:
|
|
return jsonify({
|
|
"error": True,
|
|
"detail": _("Camera non configurata per questa stazione"),
|
|
}), 409
|
|
camera_url, camera_source = camera
|
|
try:
|
|
resp = http_requests.post(
|
|
f"{camera_url.rstrip('/')}/camera/grab",
|
|
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
|
timeout=15,
|
|
)
|
|
except http_requests.RequestException as exc:
|
|
return jsonify({
|
|
"error": True,
|
|
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
|
}), 502
|
|
if resp.status_code != 200:
|
|
detail = ""
|
|
try:
|
|
body = resp.json()
|
|
detail = body.get("detail", "") if isinstance(body, dict) else ""
|
|
except ValueError:
|
|
detail = (resp.text or "")[:200]
|
|
return jsonify({
|
|
"error": True,
|
|
"detail": _("Acquisizione non riuscita (%(status)s): %(detail)s",
|
|
status=resp.status_code, detail=detail),
|
|
}), 502
|
|
return Response(resp.content, content_type="image/png")
|
|
|
|
|
|
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
|
@login_required
|
|
@role_required("MeasurementTec")
|
|
def api_vision_result_image(result_id: int):
|
|
"""Proxy: the image saved with a vision result (needs X-API-Key)."""
|
|
base_url = Config.API_SERVER_URL.rstrip("/")
|
|
resp = http_requests.get(
|
|
f"{base_url}/api/vision/results/{result_id}/image",
|
|
headers={"X-API-Key": session.get("api_key", "")},
|
|
timeout=30,
|
|
)
|
|
if resp.status_code != 200:
|
|
return Response(resp.text, status=resp.status_code)
|
|
return Response(
|
|
resp.content,
|
|
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
|
)
|