feat: FASE 4 - Editor Maker (Fabric.js) con annotazioni, task editor, preview e storico versioni
- recipe_list.html: lista ricette con filtri, paginazione, cards Alpine.js - recipe_editor.html: form metadati, upload drag-and-drop, canvas Fabric.js per annotazioni - annotation-editor.js: editor annotazioni Fabric.js (marker, frecce, rettangoli, zoom, pan) - task_editor.html: editor task/subtask inline con drag-and-drop reorder e tolleranze - recipe_preview.html: anteprima ricetta come MeasurementTec - version_history.html: timeline versioni con conteggio misurazioni AJAX - maker.py: 6 route pagina + 13 proxy AJAX, gestione sicura risposte lista API - i18n: 170+ stringhe tradotte IT/EN per tutti i template Maker Architect review: 3 CRITICO + 5 MEDIO + 3 NEW risolti, 2 BASSO differiti Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+354
-19
@@ -1,45 +1,380 @@
|
||||
"""Maker blueprint - recipe creation and editing."""
|
||||
from flask import Blueprint, render_template, session, redirect, url_for
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
|
||||
maker_bp = Blueprint("maker", __name__)
|
||||
from blueprints.auth import login_required, role_required
|
||||
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."""
|
||||
if "user" not in session:
|
||||
return redirect(url_for("auth.login"))
|
||||
return render_template("maker/recipe_list.html")
|
||||
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 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", [])
|
||||
total = resp.get("total", 0)
|
||||
pages = resp.get("pages", 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")
|
||||
def recipe_editor(recipe_id: int | None = None):
|
||||
"""Recipe editor - create or edit."""
|
||||
if "user" not in session:
|
||||
return redirect(url_for("auth.login"))
|
||||
return render_template("maker/recipe_editor.html", recipe_id=recipe_id)
|
||||
@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."""
|
||||
if "user" not in session:
|
||||
return redirect(url_for("auth.login"))
|
||||
return render_template("maker/task_editor.html", recipe_id=recipe_id)
|
||||
# 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."""
|
||||
if "user" not in session:
|
||||
return redirect(url_for("auth.login"))
|
||||
return render_template("maker/recipe_preview.html", recipe_id=recipe_id)
|
||||
# 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."""
|
||||
if "user" not in session:
|
||||
return redirect(url_for("auth.login"))
|
||||
return render_template("maker/version_history.html", recipe_id=recipe_id)
|
||||
# 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=["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
|
||||
|
||||
Reference in New Issue
Block a user