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:
@@ -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"),
|
||||
)
|
||||
Reference in New Issue
Block a user