Files
TieMeasureFlow/client/blueprints/measure.py
T
Adriano dbfb5591c5 fix: separate recipe preview image from task images
Recipe image_path is now used as preview thumbnail only. Removed
auto-creation of "Technical Drawing" task from recipe upload, and
removed recipe image strip from task_execute view. Each task displays
its own file_path independently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:29:27 +01:00

333 lines
12 KiB
Python

"""MeasurementTec blueprint - recipe selection and measurement execution."""
import requests as http_requests
from flask import (
Blueprint, Response, 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
measure_bp = Blueprint("measure", __name__)
# ---------------------------------------------------------------------------
# Route: Recipe selection
# ---------------------------------------------------------------------------
@measure_bp.route("/select")
@login_required
@role_required("MeasurementTec")
def select_recipe():
"""Recipe selection page with search and barcode support."""
# Load recipes from API
resp = api_client.get("/api/recipes", params={"per_page": 100})
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,
auto_recipe_code=auto_recipe_code,
auto_lot=auto_lot,
auto_serial=auto_serial,
)
# ---------------------------------------------------------------------------
# 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."""
# Persist lot/serial from query params into 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
# 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"))
# Load tasks for this recipe
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, dict) and tasks_resp.get("error"):
flash(
_("Errore nel caricamento dei task: %(detail)s",
detail=tasks_resp.get("detail", "")),
"error",
)
tasks = []
else:
tasks = tasks_resp if isinstance(tasks_resp, list) else tasks_resp.get("items", [])
return render_template(
"measure/task_list.html",
recipe=recipe_resp,
tasks=tasks,
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"))
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
# Load all task IDs for this recipe (ordered) for auto-advance
recipe_id = task_resp.get("recipe_id")
all_task_ids = []
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]
return render_template(
"measure/task_execute.html",
task=task_resp,
lot_number=lot_number,
serial_number=serial_number,
all_task_ids=all_task_ids,
)
# ---------------------------------------------------------------------------
# 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")
# 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:
meas_resp = api_client.get(
"/api/measurements",
params={"version_id": version_id, "per_page": 500},
)
if not (isinstance(meas_resp, dict) and meas_resp.get("error")):
raw = (
meas_resp if isinstance(meas_resp, list)
else meas_resp.get("items", [])
)
# 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
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
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", "").strip()
if not code:
return jsonify({"error": True, "detail": _("Codice non fornito")}), 400
resp = api_client.get(f"/api/recipes/code/{code}")
if resp.get("error"):
return jsonify({
"error": True,
"detail": resp.get("detail", _("Ricetta non trovata")),
}), 404
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"),
}
resp = api_client.post("/api/measurements", data=payload)
if resp.get("error"):
status_code = resp.get("status_code", 500)
return jsonify({
"error": True,
"detail": resp.get("detail", _("Errore nel salvataggio")),
}), status_code if status_code >= 400 else 500
return jsonify(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)."""
api_key = session.get("api_key", "")
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/files/{file_path}",
headers={"X-API-Key": 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"),
)