Files
TieMeasureFlow/client/blueprints/maker.py
T
Adriano b075115cef fix: file display, persistence, PDF support and save error handling
- Add file proxy route in maker blueprint (X-API-Key auth for browser requests)
- Persist file_path/annotations_json to DB via RecipeCreate/RecipeUpdate schemas
- Fix canvas sizing using grandparent container instead of Fabric.js wrapper div
- Defer canvas init with requestAnimationFrame for x-show timing
- Add PDF.js support in annotation-editor and annotation-viewer
- Fix annotations_json double-serialization (parse string to object before send)
- Handle FastAPI 422 validation error arrays in api_client and JS error display
- Update template URLs to use /maker/api/files/ proxy path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 22:04:45 +01:00

403 lines
12 KiB
Python

"""Maker blueprint - recipe creation and editing."""
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
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>/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
@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)."""
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"),
)
@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