chore(v2): restructure monorepo to src/ layout with uv

Aligns the repo with the python-project-spec-design.md template chosen
for V2.0.0. Big move, no logic changes. The 3 pre-existing test
failures (test_recipes::test_update_recipe, test_recipes::
test_recipe_versioning, test_tasks::test_reorder_tasks, plus the
client test_save_measurement_proxy) survive unchanged.

Layout changes
- server/        -> src/backend/
- server/middleware/ -> src/backend/api/middleware/
- server/routers/    -> src/backend/api/routers/
- server/models/     -> src/backend/models/orm/
- server/schemas/    -> src/backend/models/api/
- server/uploads/    -> uploads/ (project root, mounted volume)
- server/tests/      -> src/backend/tests/
- client/            -> src/frontend/flask_app/ (Flask kept; React
  deroga is documented in CLAUDE.md, justified by tablet UX, USB
  caliper/barcode workflow and Fabric.js integration)

Tooling
- pyproject.toml: monorepo with [project] core deps and
  optional-dependencies server / client / dev. Replaces both
  server/requirements.txt and client/requirements.txt.
- uv.lock + .python-version (3.11) committed for reproducible builds.
- Dockerfile (root, backend) and Dockerfile.frontend rewritten to use
  uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles
  preserved as Dockerfile.legacy for reference but excluded from build
  context via .dockerignore.
- docker-compose.dev.yml + docker-compose.yml: build context now ".",
  dockerfile pointing to the root files.

Code adjustments forced by the move
- Every "from config|database|models|schemas|services|routers|middleware
  import ..." rewritten to its src.backend.* equivalent (50+ files
  including indented inline imports inside test bodies).
- src/backend/migrations/env.py: insert project root into sys.path so
  alembic can resolve src.backend.* imports regardless of cwd.
- src/backend/config.py: env_file ../../.env (was ../.env), upload_path
  resolves project root via parents[2].
- src/backend/tests/conftest.py + tests: import ... from src.backend.*
  instead of bare names; old per-directory pytest.ini files removed in
  favor of root pyproject.toml [tool.pytest.ini_options].
- .gitignore: uploads/ at root, src/frontend/flask_app/static/css/
  tailwind.css path; .dockerignore tightened.
- CLAUDE.md: rewrote sections "Layout del repository", "Comandi di
  Sviluppo", "Database & Migrations", "Test", "i18n", and all path
  references throughout the architecture sections.

Verified
- uv lock resolves 77 packages; uv sync --extra server --extra client
  --extra dev installs cleanly.
- uv run pytest: 171 passed, 4 pre-existing failures.
- uv run alembic -c src/backend/migrations/alembic.ini check loads
  config and metadata (errors only on the absent local MySQL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:26:47 +02:00
parent 86df67f2e5
commit 1a0431366f
174 changed files with 2568 additions and 308 deletions
+200
View File
@@ -0,0 +1,200 @@
"""Admin blueprint - user management."""
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
from services.api_client import api_client
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
def admin_required(f):
"""Decorator to require admin privileges."""
from functools import wraps
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
if not user.get("is_admin"):
flash(_("Accesso non autorizzato"), "error")
return redirect(url_for("measure.select_recipe"))
return f(*args, **kwargs)
return decorated
# ============================================================================
# PAGINE (GET)
# ============================================================================
@admin_bp.route("/users")
@login_required
@admin_required
def user_list():
"""User management page."""
resp = api_client.get("/api/users")
if isinstance(resp, dict) and resp.get("error"):
flash(_("Errore nel caricamento degli utenti: %(error)s", error=resp.get("detail", "")), "error")
users = []
elif isinstance(resp, list):
users = resp
else:
users = []
return render_template("admin/users.html", users=users)
@admin_bp.route("/stations")
@login_required
@admin_required
def station_list():
"""Station management page."""
resp = api_client.get("/api/stations")
if isinstance(resp, dict) and resp.get("error"):
flash(_("Errore nel caricamento delle stazioni: %(error)s", error=resp.get("detail", "")), "error")
stations = []
elif isinstance(resp, list):
stations = resp
else:
stations = []
recipes_resp = api_client.get("/api/recipes")
if isinstance(recipes_resp, list):
all_recipes = recipes_resp
elif isinstance(recipes_resp, dict) and isinstance(recipes_resp.get("items"), list):
all_recipes = recipes_resp["items"]
else:
all_recipes = []
return render_template("admin/stations.html", stations=stations, all_recipes=all_recipes)
# ============================================================================
# API PROXY AJAX (JSON)
# ============================================================================
@admin_bp.route("/api/users", methods=["POST"])
@login_required
@admin_required
def api_create_user():
"""Proxy: Create new user."""
data = request.get_json(silent=True) or {}
resp = api_client.post("/api/users", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@admin_bp.route("/api/users/<int:user_id>", methods=["PUT"])
@login_required
@admin_required
def api_update_user(user_id: int):
"""Proxy: Update user."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/users/{user_id}", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/users/<int:user_id>/password", methods=["PUT"])
@login_required
@admin_required
def api_change_password(user_id: int):
"""Proxy: Change user password."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/users/{user_id}/password", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
@login_required
@admin_required
def api_toggle_active(user_id: int):
"""Proxy: Toggle user active status."""
data = request.get_json(silent=True) or {}
active = data.get("active", True)
resp = api_client.put(f"/api/users/{user_id}", data={"active": active})
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# --- Stations ---
@admin_bp.route("/api/stations", methods=["POST"])
@login_required
@admin_required
def api_create_station():
"""Proxy: Create a new station."""
data = request.get_json(silent=True) or {}
resp = api_client.post("/api/stations", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@admin_bp.route("/api/stations/<int:station_id>", methods=["PUT"])
@login_required
@admin_required
def api_update_station(station_id: int):
"""Proxy: Update a station."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/stations/{station_id}", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/stations/<int:station_id>", methods=["DELETE"])
@login_required
@admin_required
def api_delete_station(station_id: int):
"""Proxy: Delete a station."""
resp = api_client.delete(f"/api/stations/{station_id}")
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify({"deleted": True}), 200
@admin_bp.route("/api/stations/<int:station_id>/recipes", methods=["GET"])
@login_required
@admin_required
def api_list_station_recipes(station_id: int):
"""Proxy: List recipes assigned to a station."""
resp = api_client.get(f"/api/stations/{station_id}/recipes")
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/stations/<int:station_id>/recipes", methods=["POST"])
@login_required
@admin_required
def api_assign_recipe(station_id: int):
"""Proxy: Assign a recipe to a station."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/stations/{station_id}/recipes", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@admin_bp.route("/api/stations/<int:station_id>/recipes/<int:recipe_id>", methods=["DELETE"])
@login_required
@admin_required
def api_unassign_recipe(station_id: int, recipe_id: int):
"""Proxy: Remove a recipe assignment from a station."""
resp = api_client.delete(f"/api/stations/{station_id}/recipes/{recipe_id}")
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify({"deleted": True}), 200
+160
View File
@@ -0,0 +1,160 @@
"""Authentication blueprint - login, logout, profile."""
from functools import wraps
from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from services.api_client import api_client
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
def login_required(f):
"""Decorator to require login for protected routes."""
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("api_key"):
flash(_("Effettua il login per continuare"), "warning")
return redirect(url_for("auth.login"))
return f(*args, **kwargs)
return decorated
def role_required(*roles):
"""Decorator to require one of the specified roles.
Usage:
@role_required("MeasurementTec", "Metrologist")
def my_view(): ...
"""
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
user_roles = user.get("roles", [])
if not any(r in user_roles for r in roles):
abort(403)
return f(*args, **kwargs)
return decorated
return decorator
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
"""Login page and form handler."""
# Se già loggato, redirect a home
if session.get("api_key"):
try:
return redirect(url_for("measure.select_recipe"))
except Exception:
return redirect(url_for("auth.profile"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not username or not password:
flash(_("Inserisci username e password"), "error")
return render_template("auth/login.html")
try:
response = api_client.post("/api/auth/login", data={"username": username, "password": password})
if response.get("error"):
flash(response.get("detail", _("Credenziali non valide")), "error")
return render_template("auth/login.html")
# Salva in sessione
session["api_key"] = response["api_key"]
session["user"] = response["user"]
session["user_id"] = response["user"]["id"]
# Applica preferenze utente
user = response["user"]
if user.get("language_pref"):
session["language"] = user["language_pref"]
if user.get("theme_pref"):
session["theme"] = user["theme_pref"]
# Carica logo aziendale dalle impostazioni di sistema
settings_resp = api_client.get("/api/settings")
if not settings_resp.get("error"):
logo_path = settings_resp.get("company_logo_path")
if logo_path:
session["company_logo"] = logo_path
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
# Redirect dopo login
try:
return redirect(url_for("measure.select_recipe"))
except Exception:
return redirect(url_for("auth.profile"))
except Exception as e:
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
return render_template("auth/login.html")
return render_template("auth/login.html")
@auth_bp.route("/logout", methods=["GET", "POST"])
def logout():
"""Clear session and redirect to login."""
# Invalida il token sul server
if session.get("api_key"):
try:
api_client.post("/api/auth/logout")
except Exception:
pass # Ignora errori durante logout
session.clear()
flash(_("Logout effettuato"), "info")
return redirect(url_for("auth.login"))
@auth_bp.route("/profile", methods=["GET", "POST"])
@login_required
def profile():
"""User profile page - change display name, language, theme."""
if request.method == "POST":
display_name = request.form.get("display_name", "").strip()
language_pref = request.form.get("language_pref", "").strip()
theme_pref = request.form.get("theme_pref", "").strip()
try:
data = {}
if display_name:
data["display_name"] = display_name
if language_pref:
data["language_pref"] = language_pref
if theme_pref:
data["theme_pref"] = theme_pref
response = api_client.put("/api/auth/me", data=data)
if response.get("error"):
flash(response.get("detail", _("Errore durante l'aggiornamento del profilo")), "error")
else:
# Aggiorna sessione
session["user"] = response
if language_pref:
session["language"] = language_pref
if theme_pref:
session["theme"] = theme_pref
flash(_("Profilo aggiornato con successo"), "success")
return redirect(url_for("auth.profile"))
except Exception as e:
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
# GET - carica dati profilo
user_data = api_client.get("/api/auth/me")
if user_data.get("error"):
flash(_("Errore nel caricamento del profilo: %(error)s", error=user_data.get("detail", "")), "error")
user_data = session.get("user", {})
else:
session["user"] = user_data
return render_template("auth/profile.html", user=user_data)
+426
View File
@@ -0,0 +1,426 @@
"""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>/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
@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
@@ -0,0 +1,343 @@
"""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."""
# Fail-fast if STATION_CODE is not configured
if not Config.STATION_CODE:
return render_template("errors/station_not_configured.html"), 503
# Load recipes filtered by station
try:
resp = api_client.get_station_recipes(Config.STATION_CODE)
except Exception as e:
return render_template(
"errors/station_not_configured.html", error=str(e),
), 502
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,
station_code=Config.STATION_CODE,
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"),
)
@@ -0,0 +1,178 @@
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
import requests as http_requests
from flask import Blueprint, jsonify, render_template, request, session
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
statistics_bp = Blueprint("statistics", __name__)
# ============================================================================
# PAGINE
# ============================================================================
@statistics_bp.route("/dashboard")
@login_required
@role_required("Metrologist")
def dashboard():
"""SPC dashboard overview — loads recipes for filter dropdown."""
resp = api_client.get("/api/recipes", params={"per_page": 100})
if isinstance(resp, dict) and resp.get("error"):
recipes = []
else:
recipes = resp.get("items", []) if isinstance(resp, dict) else []
return render_template("statistics/dashboard.html", recipes=recipes)
# ============================================================================
# PROXY API AJAX → FastAPI
# ============================================================================
def _proxy_statistics(endpoint: str):
"""Forward query params to a FastAPI statistics endpoint."""
params = {}
for key in [
"recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id",
"lot_number", "serial_number", "n_bins",
]:
val = request.args.get(key)
if val is not None and val != "":
params[key] = val
resp = api_client.get(f"/api/statistics/{endpoint}", params=params)
if isinstance(resp, dict) and resp.get("error"):
return jsonify({"error": True, "detail": resp.get("detail", "")}), resp.get("status_code", 500)
return jsonify(resp)
@statistics_bp.route("/api/summary")
@login_required
@role_required("Metrologist")
def api_summary():
"""Proxy: summary pass/fail/warning."""
return _proxy_statistics("summary")
@statistics_bp.route("/api/capability")
@login_required
@role_required("Metrologist")
def api_capability():
"""Proxy: capability indices."""
return _proxy_statistics("capability")
@statistics_bp.route("/api/control-chart")
@login_required
@role_required("Metrologist")
def api_control_chart():
"""Proxy: control chart data."""
return _proxy_statistics("control-chart")
@statistics_bp.route("/api/histogram")
@login_required
@role_required("Metrologist")
def api_histogram():
"""Proxy: histogram data."""
return _proxy_statistics("histogram")
@statistics_bp.route("/api/subtasks")
@login_required
@role_required("Metrologist")
def api_subtasks():
"""Proxy: get subtasks for recipe filter."""
params = {}
recipe_id = request.args.get("recipe_id")
if recipe_id:
params["recipe_id"] = recipe_id
version_id = request.args.get("version_id")
if version_id:
params["version_id"] = version_id
resp = api_client.get("/api/statistics/subtasks", params=params)
if isinstance(resp, dict) and resp.get("error"):
return jsonify({"error": True, "detail": resp.get("detail", "")}), resp.get("status_code", 500)
# Response is a list
if isinstance(resp, list):
return jsonify(resp)
return jsonify(resp)
# ============================================================================
# PROXY REPORT PDF → FastAPI
# ============================================================================
def _proxy_report(endpoint: str):
"""Forward query params to a FastAPI reports endpoint and return PDF."""
params = {}
for key in [
"recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id",
"lot_number", "serial_number",
]:
val = request.args.get(key)
if val is not None and val != "":
params[key] = val
base_url = Config.API_SERVER_URL.rstrip("/")
headers = {}
api_key = session.get("api_key")
if api_key:
headers["X-API-Key"] = api_key
try:
resp = http_requests.get(
f"{base_url}/api/reports/{endpoint}",
headers=headers,
params=params,
timeout=120,
)
except (http_requests.ConnectionError, http_requests.Timeout) as e:
return jsonify({"error": True, "detail": str(e)}), 502
if not resp.ok:
try:
err = resp.json()
detail = err.get("detail", f"HTTP {resp.status_code}")
except Exception:
detail = f"HTTP {resp.status_code}"
return jsonify({"error": True, "detail": detail}), resp.status_code
from flask import Response as FlaskResponse
return FlaskResponse(
resp.content,
mimetype="application/pdf",
headers={
"Content-Disposition": resp.headers.get(
"Content-Disposition", f'attachment; filename="report.pdf"'
)
},
)
@statistics_bp.route("/api/report-spc")
@login_required
@role_required("Metrologist")
def api_report_spc():
"""Proxy: download SPC PDF report."""
return _proxy_report("spc")
@statistics_bp.route("/api/report-measurements")
@login_required
@role_required("Metrologist")
def api_report_measurements():
"""Proxy: download measurements PDF report."""
return _proxy_report("measurements")