Files
TieMeasureFlow/src/frontend/flask_app/blueprints/maker.py
T

831 lines
29 KiB
Python

"""Maker blueprint - recipe creation and editing."""
import json
import math
import requests as http_requests
from functools import wraps
from urllib.parse import quote # #105: percorso del file ricetta nell'URL del backend
from flask import Blueprint, Response, 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
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
# #106: la prima apertura GenTL dopo l'avvio supera 15 s, il grab ha bisogno di piu margine
CAMERA_GRAB_TIMEOUT_S = 60
# ============================================================================
# PAGINE (GET)
# ============================================================================
@maker_bp.route("/recipes")
@login_required
@role_required("Maker")
def recipe_list():
"""List all recipes with filters."""
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 100, type=int)
search = request.args.get("search", "", type=str)
params = {"page": page, "per_page": per_page}
if search:
params["search"] = search
resp = api_client.get("/api/recipes", params=params)
if isinstance(resp, dict) and resp.get("error"):
flash(_("Errore nel caricamento delle ricette: %(error)s", error=resp.get("detail", "")), "error")
recipes = []
total = 0
pages = 0
else:
recipes = resp.get("items", []) if isinstance(resp, dict) else resp
total = resp.get("total", 0) if isinstance(resp, dict) else len(recipes)
pages = resp.get("pages", 1) if isinstance(resp, dict) else 1
return render_template(
"maker/recipe_list.html",
recipes=recipes,
page=page,
per_page=per_page,
total=total,
pages=pages,
search=search
)
@maker_bp.route("/recipes/new")
@login_required
@role_required("Maker")
def recipe_new():
"""Create new recipe."""
# #49: loop duration pre-filled from the system default (new recipes only);
# missing/empty/invalid key -> None -> behaviour unchanged
default_interval = None
settings = api_client.get("/api/settings")
if isinstance(settings, dict) and not settings.get("error"):
try:
value = int(str(settings.get("default_measurement_interval_minutes", "")).strip())
if 1 <= value <= 1440:
default_interval = value
except ValueError:
pass
return render_template(
"maker/recipe_editor.html", recipe=None, default_interval=default_interval
)
@maker_bp.route("/recipes/<int:recipe_id>/edit")
@login_required
@role_required("Maker")
def recipe_edit(recipe_id: int):
"""Edit existing recipe."""
# Carica recipe con dettagli completi
resp = api_client.get(f"/api/recipes/{recipe_id}")
if resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
recipe = resp
# Carica anche le versioni
versions_resp = api_client.get(f"/api/recipes/{recipe_id}/versions")
if isinstance(versions_resp, list):
recipe["versions"] = versions_resp
else:
recipe["versions"] = []
return render_template("maker/recipe_editor.html", recipe=recipe)
@maker_bp.route("/recipes/<int:recipe_id>/tasks")
@login_required
@role_required("Maker")
def task_editor(recipe_id: int):
"""Task/subtask editor with tolerances."""
# Carica recipe con task/subtask
resp = api_client.get(f"/api/recipes/{recipe_id}")
if resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
recipe = resp
# Carica tasks con subtasks
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, list):
recipe["tasks"] = tasks_resp
else:
recipe["tasks"] = []
# #47/#48: liste e tolleranze generali dalle Impostazioni; chiave assente,
# vuota o non valida -> None -> il template usa le voci scritte a mano
settings = api_client.get("/api/settings")
if not isinstance(settings, dict) or settings.get("error"):
settings = {}
return render_template(
"maker/task_editor.html",
recipe=recipe,
list_measurement_types=_settings_string_list(settings.get("list_measurement_types")),
list_units=_settings_string_list(settings.get("list_units")),
general_tolerance_plus=_settings_tolerance(settings.get("general_tolerance_plus")),
general_tolerance_minus=_settings_tolerance(settings.get("general_tolerance_minus")),
)
def _settings_string_list(value):
"""#47: array JSON di stringhe non vuote dalle Impostazioni, altrimenti None."""
try:
items = json.loads(value) if isinstance(value, str) else None
except ValueError:
return None
if not isinstance(items, list):
return None
items = [i.strip() for i in items if isinstance(i, str) and i.strip()]
return items or None
def _settings_tolerance(value):
"""#48: tolleranza generale (decimale >= 0) dalle Impostazioni, altrimenti None."""
try:
number = float(str(value).strip())
except ValueError:
return None
return number if math.isfinite(number) and number >= 0 else None
@maker_bp.route("/recipes/<int:recipe_id>/tasks/<int:task_id>/drawing")
@login_required
@role_required("Maker")
def task_drawing(recipe_id: int, task_id: int):
"""Annotation editor for a specific task."""
# Load recipe for breadcrumb context
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
if recipe_resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=recipe_resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
# Load task details
task_resp = api_client.get(f"/api/tasks/{task_id}")
if task_resp.get("error"):
flash(_("Task non trovato: %(error)s", error=task_resp.get("detail", "")), "error")
return redirect(url_for("maker.task_editor", recipe_id=recipe_id))
return render_template(
"maker/task_drawing.html",
recipe=recipe_resp,
task=task_resp,
)
@maker_bp.route("/recipes/<int:recipe_id>/preview")
@login_required
@role_required("Maker")
def recipe_preview(recipe_id: int):
"""Preview recipe as MeasurementTec would see it."""
# Carica recipe completo
resp = api_client.get(f"/api/recipes/{recipe_id}")
if resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
recipe = resp
# Carica tasks con subtasks
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, list):
recipe["tasks"] = tasks_resp
else:
recipe["tasks"] = []
return render_template("maker/recipe_preview.html", recipe=recipe)
@maker_bp.route("/recipes/<int:recipe_id>/versions")
@login_required
@role_required("Maker")
def version_history(recipe_id: int):
"""Version history with diff."""
# Carica recipe base
resp = api_client.get(f"/api/recipes/{recipe_id}")
if resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
recipe = resp
# Carica versioni
versions_resp = api_client.get(f"/api/recipes/{recipe_id}/versions")
if isinstance(versions_resp, list):
versions = versions_resp
else:
versions = []
flash(_("Errore nel caricamento delle versioni: %(error)s",
error=versions_resp.get("detail", "") if isinstance(versions_resp, dict) else ""), "warning")
return render_template("maker/version_history.html", recipe=recipe, versions=versions)
# ============================================================================
# API PROXY AJAX (JSON)
# ============================================================================
@maker_bp.route("/api/recipes", methods=["POST"])
@login_required
@role_required("Maker")
def api_create_recipe():
"""Proxy: Create new recipe."""
data = request.get_json(silent=True) or {}
resp = api_client.post("/api/recipes", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/recipes/<int:recipe_id>/duplicate", methods=["POST"])
@login_required
@role_required("Maker")
def api_duplicate_recipe(recipe_id: int):
"""Proxy: Duplicate a recipe under a new code/name (#52)."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/recipes/{recipe_id}/duplicate", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"])
@login_required
@role_required("Maker")
def api_update_recipe(recipe_id: int):
"""Proxy: Update recipe (creates new version)."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/recipes/{recipe_id}", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["DELETE"])
@login_required
@role_required("Maker")
def api_delete_recipe(recipe_id: int):
"""Proxy: Delete recipe (soft delete)."""
resp = api_client.delete(f"/api/recipes/{recipe_id}")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# ============================================================================
# TASK API PROXY
# ============================================================================
@maker_bp.route("/api/recipes/<int:recipe_id>/tasks", methods=["POST"])
@login_required
@role_required("Maker")
def api_create_task(recipe_id: int):
"""Proxy: Create new task."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/recipes/{recipe_id}/tasks", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/tasks/<int:task_id>", methods=["PUT"])
@login_required
@role_required("Maker")
def api_update_task(task_id: int):
"""Proxy: Update task."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/tasks/{task_id}", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/tasks/<int:task_id>", methods=["DELETE"])
@login_required
@role_required("Maker")
def api_delete_task(task_id: int):
"""Proxy: Delete task."""
resp = api_client.delete(f"/api/tasks/{task_id}")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
# 204 No Content returns empty dict
return jsonify(resp), 204
@maker_bp.route("/api/tasks/reorder", methods=["PUT"])
@login_required
@role_required("Maker")
def api_reorder_tasks():
"""Proxy: Reorder tasks."""
data = request.get_json(silent=True) or {}
resp = api_client.put("/api/tasks/reorder", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# ============================================================================
# SUBTASK API PROXY
# ============================================================================
@maker_bp.route("/api/tasks/<int:task_id>/subtasks", methods=["POST"])
@login_required
@role_required("Maker")
def api_create_subtask(task_id: int):
"""Proxy: Create new subtask."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/tasks/{task_id}/subtasks", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/subtasks/<int:subtask_id>", methods=["PUT"])
@login_required
@role_required("Maker")
def api_update_subtask(subtask_id: int):
"""Proxy: Update subtask."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/subtasks/{subtask_id}", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/subtasks/<int:subtask_id>", methods=["DELETE"])
@login_required
@role_required("Maker")
def api_delete_subtask(subtask_id: int):
"""Proxy: Delete subtask."""
resp = api_client.delete(f"/api/subtasks/{subtask_id}")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
# 204 No Content returns empty dict
return jsonify(resp), 204
# ============================================================================
# FILE API PROXY
# ============================================================================
@maker_bp.route("/api/upload", methods=["POST"])
@login_required
@role_required("Maker")
def api_upload_file():
"""Proxy: Upload file (multipart forward)."""
# Controlla se c'è un file
if "file" not in request.files:
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
file = request.files["file"]
if file.filename == "":
return jsonify({"error": True, "detail": _("Nome file vuoto")}), 400
# Prepara multipart data
recipe_id = request.form.get("recipe_id")
version_id = request.form.get("version_id")
files = {"file": (file.filename, file.stream, file.content_type)}
data = {}
if recipe_id:
data["recipe_id"] = recipe_id
if version_id:
data["version_id"] = version_id
# Forward multipart request
resp = api_client.post("/api/files/upload", data=data, files=files)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
# ============================================================================
# VISION RECIPE PROXY (#46)
# ============================================================================
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe", methods=["GET"])
@login_required
@role_required("Maker")
def api_get_vision_recipe(task_id: int):
"""Proxy: ricetta visione corrente del task (cartella, file, uscite)."""
resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe", methods=["POST"])
@login_required
@role_required("Maker")
def api_upload_vision_recipe(task_id: int):
"""Proxy: carica ricetta .json + file allegati (multipart forward)."""
recipe = request.files.get("recipe")
if recipe is None or recipe.filename == "":
return jsonify({"error": True, "detail": _("Nessun file ricetta caricato")}), 400
# Ricetta + 0..n allegati (DXF, modelli .npz, calibrazione), stesso nome campo `files`
files = [("recipe", (recipe.filename, recipe.stream, recipe.content_type))]
for f in request.files.getlist("files"):
if f.filename:
files.append(("files", (f.filename, f.stream, f.content_type)))
resp = api_client.post(f"/api/vision/tasks/{task_id}/recipe", data={}, files=files)
# Il 422 (errori di validazione) arriva al browser com'è, con `errors`
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# ============================================================================
# RICETTA DXF DA CAMERA (#83): stazioni, camera, calibrazione, creazione ricetta
# ============================================================================
def maker_or_admin_required(f):
"""Maker dell'editor oppure admin della pagina stazioni (#83: stesse rotte camera)."""
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
if not (user.get("is_admin") or "Maker" in user.get("roles", [])):
abort(403)
return f(*args, **kwargs)
return decorated
def _stations_for_maker() -> list[dict]:
"""Stazioni visibili al chiamante.
GET /api/stations e' solo admin: un Maker non admin ripiega sulla stazione
corrente (by-code, aperta a tutti), cosi' l'editor funziona comunque.
"""
resp = api_client.get("/api/stations")
if isinstance(resp, list):
return resp
if Config.STATION_CODE:
one = api_client.get_station_by_code(Config.STATION_CODE)
if isinstance(one, dict) and not one.get("error"):
return [one]
return []
def _camera_of_station(station_id: int) -> tuple[str, str] | None:
"""(camera_url, camera_source) della stazione con questo id, None se senza camera."""
for st in _stations_for_maker():
if st.get("id") == station_id:
if st.get("camera_url") and st.get("camera_source"):
return str(st["camera_url"]), str(st["camera_source"])
return None
return None
@maker_bp.route("/api/stations", methods=["GET"])
@login_required
@maker_or_admin_required
def api_list_stations_for_maker():
"""Elenco stazioni con camera per il pannello «Crea ricetta da DXF»."""
out = []
for st in _stations_for_maker():
if st.get("camera_url") and st.get("camera_source"):
out.append({
"id": st.get("id"), "code": st.get("code"), "name": st.get("name"),
"camera_url": st.get("camera_url"),
})
return jsonify(out), 200
def _camera_services() -> list[dict]:
"""#97: servizi di acquisizione distinti delle stazioni che hanno una camera_url."""
services: dict[str, list[str]] = {}
for st in _stations_for_maker():
url = (st.get("camera_url") or "").strip().rstrip("/")
if not url:
continue
label = f"{st.get('code') or ''} - {st.get('name') or ''}"
services.setdefault(url, []).append(label)
return [{"camera_url": u, "stazioni": s} for u, s in services.items()]
@maker_bp.route("/api/camera/servizi", methods=["GET"])
@login_required
@maker_or_admin_required
def api_camera_services():
"""#97: servizi di acquisizione fra cui scegliere la camera di un task."""
return jsonify({"servizi": _camera_services()}), 200
@maker_bp.route("/api/camera/sorgenti", methods=["GET"])
@login_required
@maker_or_admin_required
def api_camera_sources():
"""#97: proxy di GET {url}/camera/sources; solo per i servizi delle stazioni."""
url = (request.args.get("url") or "").strip().rstrip("/")
# Niente richieste verso indirizzi arbitrari: url ammesso solo se e' un servizio noto
if not url or url not in {s["camera_url"] for s in _camera_services()}:
return jsonify({
"error": True,
"detail": _("Servizio di acquisizione non ammesso"),
}), 400
try:
resp = http_requests.get(f"{url}/camera/sources", timeout=15)
body = resp.json() if resp.status_code == 200 else None
except (http_requests.RequestException, ValueError) as exc:
return jsonify({
"error": True,
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
sources = body.get("sources") if isinstance(body, dict) else None
if not isinstance(sources, list):
return jsonify({
"error": True,
"detail": _("Risposta del servizio camera non valida (%(status)s)", status=resp.status_code),
}), 502
return jsonify({"sources": sources}), 200
# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id
@maker_bp.route("/api/stations/<int:station_id>/camera/grab", methods=["GET", "POST"])
@login_required
@maker_or_admin_required
def api_station_camera_grab(station_id: int):
"""Proxy: un frame dalla camera della stazione, risposta PNG."""
camera = _camera_of_station(station_id)
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=CAMERA_GRAB_TIMEOUT_S, # #106: era 15, troppo corto alla prima apertura GenTL
)
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")
@maker_bp.route("/api/stations/<int:station_id>/camera/calibration", methods=["GET"])
@login_required
@maker_or_admin_required
def api_station_camera_calibration(station_id: int):
"""Proxy: calibrazione della camera letta dall'acquisitore (C1).
Risponde sempre 200 con `state`: calibrated | uncalibrated | unreachable,
cosi' la UI mostra lo stato senza trattare «non calibrata» come errore.
409 solo se la stazione non ha camera.
"""
camera = _camera_of_station(station_id)
if camera is None:
return jsonify({
"error": True,
"detail": _("Camera non configurata per questa stazione"),
}), 409
camera_url, camera_source = camera
base = camera_url.rstrip("/")
link = f"{base}/acquisizione" # pagina di acquisizione dell'acquisitore (#88; era /calibrazione, C4)
try:
resp = http_requests.get(
f"{base}/camera/calibration",
params={"source": camera_source},
timeout=10,
)
except http_requests.RequestException as exc:
return jsonify({"state": "unreachable", "detail": str(exc), "calibration_url": link}), 200
if resp.status_code == 404:
return jsonify({"state": "uncalibrated", "calibration_url": link}), 200
if resp.status_code != 200:
return jsonify({
"state": "unreachable", "calibration_url": link,
"detail": _("Risposta %(status)s dall'acquisitore", status=resp.status_code),
}), 200
try:
calibration = resp.json()
except ValueError:
return jsonify({"state": "unreachable", "calibration_url": link,
"detail": _("Risposta non valida dall'acquisitore")}), 200
return jsonify({"state": "calibrated", "calibration": calibration, "calibration_url": link}), 200
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare", methods=["POST"])
@login_required
@role_required("Maker")
def api_dxf_compare_recipe(task_id: int):
"""Proxy: crea (o prova, save=false) la ricetta DXF compare da camera (B3)."""
image = request.files.get("image")
dxf = request.files.get("dxf")
if image is None or not image.filename or dxf is None or not dxf.filename:
return jsonify({"error": True, "detail": _("Servono l'immagine e il file DXF")}), 400
# Solo i campi del contratto B3; i vuoti non si inoltrano (il backend ha i default)
data = {}
for key in ("station_id", "px_per_mm", "band_mm", "min_points_pct", "polarity", "save"):
value = request.form.get(key)
if value not in (None, ""):
data[key] = value
resp = api_client.post(
f"/api/vision/tasks/{task_id}/recipe/dxf-compare",
data=data,
files={
"image": (image.filename, image.read(), image.mimetype or "application/octet-stream"),
"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream"),
},
)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# #105: parametri della ricetta DXF gia' salvata sul task, per riaprire il wizard compilato
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare/source", methods=["GET"])
@login_required
@role_required("Maker")
def api_dxf_compare_source(task_id: int):
"""Proxy: parametri e file della ricetta DXF del task (404 se non ce n'e' una) (#105)."""
resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe/dxf-compare/source")
# Codice e corpo del backend arrivano com'e': il 404 vuol dire «nessuna ricetta da DXF»
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# #105: un file della cartella ricetta (DXF, immagine di riferimento) come byte, non JSON
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/files/<path:path>", methods=["GET"])
@login_required
@role_required("Maker")
def api_vision_recipe_file(task_id: int, path: str):
"""Proxy: byte di un file della cartella ricetta del task, con content-type del backend (#105)."""
# Niente segmenti '..' o backslash: urllib3 normalizzerebbe l'URL fuori da questa rotta del backend
if "\\" in path or ".." in path.split("/"):
return jsonify({"error": True, "detail": _("File non trovato")}), 404
try:
resp = http_requests.get(
f"{Config.API_SERVER_URL.rstrip('/')}/api/vision/tasks/{task_id}/recipe/files/{quote(path)}",
headers={"X-API-Key": session.get("api_key", "")},
timeout=30,
)
except http_requests.RequestException as exc:
return jsonify({
"error": True,
"detail": _("Servizio non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
return Response(
resp.content,
status=resp.status_code,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
# #101: anteprima del DXF appena scelto nel wizard (contorni letti dal vision worker)
@maker_bp.route("/api/vision/dxf-preview", methods=["POST"])
@login_required
@role_required("Maker")
def api_dxf_preview():
"""Proxy: anteprima del DXF (contorni, bbox, L x H) prima di creare la ricetta (#101)."""
dxf = request.files.get("dxf")
if dxf is None or not dxf.filename:
return jsonify({"error": True, "detail": _("Serve il file DXF")}), 400
resp = api_client.post(
"/api/vision/dxf-preview",
files={"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream")},
)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_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)
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
@login_required
@role_required("Maker")
def api_delete_file(file_path: str):
"""Proxy: Delete file."""
resp = api_client.delete(f"/api/files/{file_path}")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
# 204 No Content returns empty dict
return jsonify(resp), 204
# ============================================================================
# VERSION API PROXY
# ============================================================================
@maker_bp.route("/api/recipes/<int:recipe_id>/versions/<int:version_number>/measurement-count", methods=["GET"])
@login_required
@role_required("Maker")
def api_get_measurement_count(recipe_id: int, version_number: int):
"""Proxy: Get measurement count for a specific version."""
resp = api_client.get(f"/api/recipes/{recipe_id}/versions/{version_number}/measurement-count")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/recipes/<int:recipe_id>/parse-technical-sheet", methods=["POST"])
@login_required
@role_required("Maker")
def api_parse_technical_sheet(recipe_id: int):
"""Proxy: Parse PDF technical sheet with AI."""
file = request.files.get("file")
if not file:
return jsonify({"error": True, "detail": "Nessun file caricato"}), 400
api_key = session.get("api_key", "")
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.post(
f"{base_url}/api/recipes/{recipe_id}/parse-technical-sheet",
headers={"X-API-Key": api_key},
files={"file": (file.filename, file.stream, file.content_type)},
timeout=90,
)
try:
payload = resp.json()
except ValueError:
payload = {
"error": True,
"detail": resp.text or f"HTTP {resp.status_code}",
}
return jsonify(payload), resp.status_code