e640c4a36e
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
485 lines
15 KiB
Python
485 lines
15 KiB
Python
"""Maker blueprint - recipe creation and editing."""
|
|
import requests as http_requests
|
|
|
|
from flask import Blueprint, 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")
|
|
|
|
|
|
# ============================================================================
|
|
# 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."""
|
|
return render_template("maker/recipe_editor.html", recipe=None)
|
|
|
|
|
|
@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"] = []
|
|
|
|
return render_template("maker/task_editor.html", recipe=recipe)
|
|
|
|
|
|
@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>", 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
|
|
|
|
|
|
@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
|