"""Maker blueprint - recipe creation and editing.""" import json import math import requests as http_requests from functools import wraps from urllib.parse import quote # #105: percorso del file ricetta nell'URL del backend from flask import Blueprint, Response, abort, 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.""" # #49: loop duration pre-filled from the system default (new recipes only); # missing/empty/invalid key -> None -> behaviour unchanged default_interval = None settings = api_client.get("/api/settings") if isinstance(settings, dict) and not settings.get("error"): try: value = int(str(settings.get("default_measurement_interval_minutes", "")).strip()) if 1 <= value <= 1440: default_interval = value except ValueError: pass return render_template( "maker/recipe_editor.html", recipe=None, default_interval=default_interval ) @maker_bp.route("/recipes//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//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"] = [] # #47/#48: liste e tolleranze generali dalle Impostazioni; chiave assente, # vuota o non valida -> None -> il template usa le voci scritte a mano settings = api_client.get("/api/settings") if not isinstance(settings, dict) or settings.get("error"): settings = {} return render_template( "maker/task_editor.html", recipe=recipe, list_measurement_types=_settings_string_list(settings.get("list_measurement_types")), list_units=_settings_string_list(settings.get("list_units")), general_tolerance_plus=_settings_tolerance(settings.get("general_tolerance_plus")), general_tolerance_minus=_settings_tolerance(settings.get("general_tolerance_minus")), ) def _settings_string_list(value): """#47: array JSON di stringhe non vuote dalle Impostazioni, altrimenti None.""" try: items = json.loads(value) if isinstance(value, str) else None except ValueError: return None if not isinstance(items, list): return None items = [i.strip() for i in items if isinstance(i, str) and i.strip()] return items or None def _settings_tolerance(value): """#48: tolleranza generale (decimale >= 0) dalle Impostazioni, altrimenti None.""" try: number = float(str(value).strip()) except ValueError: return None return number if math.isfinite(number) and number >= 0 else None @maker_bp.route("/recipes//tasks//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//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//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//duplicate", methods=["POST"]) @login_required @role_required("Maker") def api_duplicate_recipe(recipe_id: int): """Proxy: Duplicate a recipe under a new code/name (#52).""" data = request.get_json(silent=True) or {} resp = api_client.post(f"/api/recipes/{recipe_id}/duplicate", data=data) if resp.get("error"): return jsonify(resp), resp.get("status_code", 500) return jsonify(resp), 201 @maker_bp.route("/api/recipes/", 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/", 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//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/", 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/", 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//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/", 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/", 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//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//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 # ============================================================================ # RICETTA DXF DA CAMERA (#83): stazioni, camera, calibrazione, creazione ricetta # ============================================================================ def maker_or_admin_required(f): """Maker dell'editor oppure admin della pagina stazioni (#83: stesse rotte camera).""" @wraps(f) def decorated(*args, **kwargs): user = session.get("user", {}) if not (user.get("is_admin") or "Maker" in user.get("roles", [])): abort(403) return f(*args, **kwargs) return decorated def _stations_for_maker() -> list[dict]: """Stazioni visibili al chiamante. GET /api/stations e' solo admin: un Maker non admin ripiega sulla stazione corrente (by-code, aperta a tutti), cosi' l'editor funziona comunque. """ resp = api_client.get("/api/stations") if isinstance(resp, list): return resp if Config.STATION_CODE: one = api_client.get_station_by_code(Config.STATION_CODE) if isinstance(one, dict) and not one.get("error"): return [one] return [] def _camera_of_station(station_id: int) -> tuple[str, str] | None: """(camera_url, camera_source) della stazione con questo id, None se senza camera.""" for st in _stations_for_maker(): if st.get("id") == station_id: if st.get("camera_url") and st.get("camera_source"): return str(st["camera_url"]), str(st["camera_source"]) return None return None @maker_bp.route("/api/stations", methods=["GET"]) @login_required @maker_or_admin_required def api_list_stations_for_maker(): """Elenco stazioni con camera per il pannello «Crea ricetta da DXF».""" out = [] for st in _stations_for_maker(): if st.get("camera_url") and st.get("camera_source"): out.append({ "id": st.get("id"), "code": st.get("code"), "name": st.get("name"), "camera_url": st.get("camera_url"), }) return jsonify(out), 200 def _camera_services() -> list[dict]: """#97: servizi di acquisizione distinti delle stazioni che hanno una camera_url.""" services: dict[str, list[str]] = {} for st in _stations_for_maker(): url = (st.get("camera_url") or "").strip().rstrip("/") if not url: continue label = f"{st.get('code') or ''} - {st.get('name') or ''}" services.setdefault(url, []).append(label) return [{"camera_url": u, "stazioni": s} for u, s in services.items()] @maker_bp.route("/api/camera/servizi", methods=["GET"]) @login_required @maker_or_admin_required def api_camera_services(): """#97: servizi di acquisizione fra cui scegliere la camera di un task.""" return jsonify({"servizi": _camera_services()}), 200 @maker_bp.route("/api/camera/sorgenti", methods=["GET"]) @login_required @maker_or_admin_required def api_camera_sources(): """#97: proxy di GET {url}/camera/sources; solo per i servizi delle stazioni.""" url = (request.args.get("url") or "").strip().rstrip("/") # Niente richieste verso indirizzi arbitrari: url ammesso solo se e' un servizio noto if not url or url not in {s["camera_url"] for s in _camera_services()}: return jsonify({ "error": True, "detail": _("Servizio di acquisizione non ammesso"), }), 400 try: resp = http_requests.get(f"{url}/camera/sources", timeout=15) body = resp.json() if resp.status_code == 200 else None except (http_requests.RequestException, ValueError) as exc: return jsonify({ "error": True, "detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)), }), 502 sources = body.get("sources") if isinstance(body, dict) else None if not isinstance(sources, list): return jsonify({ "error": True, "detail": _("Risposta del servizio camera non valida (%(status)s)", status=resp.status_code), }), 502 return jsonify({"sources": sources}), 200 # Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id @maker_bp.route("/api/stations//camera/grab", methods=["GET", "POST"]) @login_required @maker_or_admin_required def api_station_camera_grab(station_id: int): """Proxy: un frame dalla camera della stazione, risposta PNG.""" camera = _camera_of_station(station_id) if camera is None: return jsonify({ "error": True, "detail": _("Camera non configurata per questa stazione"), }), 409 camera_url, camera_source = camera try: resp = http_requests.post( f"{camera_url.rstrip('/')}/camera/grab", json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000}, timeout=15, ) except http_requests.RequestException as exc: return jsonify({ "error": True, "detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)), }), 502 if resp.status_code != 200: detail = "" try: body = resp.json() detail = body.get("detail", "") if isinstance(body, dict) else "" except ValueError: detail = (resp.text or "")[:200] return jsonify({ "error": True, "detail": _("Acquisizione non riuscita (%(status)s): %(detail)s", status=resp.status_code, detail=detail), }), 502 return Response(resp.content, content_type="image/png") @maker_bp.route("/api/stations//camera/calibration", methods=["GET"]) @login_required @maker_or_admin_required def api_station_camera_calibration(station_id: int): """Proxy: calibrazione della camera letta dall'acquisitore (C1). Risponde sempre 200 con `state`: calibrated | uncalibrated | unreachable, cosi' la UI mostra lo stato senza trattare «non calibrata» come errore. 409 solo se la stazione non ha camera. """ camera = _camera_of_station(station_id) if camera is None: return jsonify({ "error": True, "detail": _("Camera non configurata per questa stazione"), }), 409 camera_url, camera_source = camera base = camera_url.rstrip("/") link = f"{base}/acquisizione" # pagina di acquisizione dell'acquisitore (#88; era /calibrazione, C4) try: resp = http_requests.get( f"{base}/camera/calibration", params={"source": camera_source}, timeout=10, ) except http_requests.RequestException as exc: return jsonify({"state": "unreachable", "detail": str(exc), "calibration_url": link}), 200 if resp.status_code == 404: return jsonify({"state": "uncalibrated", "calibration_url": link}), 200 if resp.status_code != 200: return jsonify({ "state": "unreachable", "calibration_url": link, "detail": _("Risposta %(status)s dall'acquisitore", status=resp.status_code), }), 200 try: calibration = resp.json() except ValueError: return jsonify({"state": "unreachable", "calibration_url": link, "detail": _("Risposta non valida dall'acquisitore")}), 200 return jsonify({"state": "calibrated", "calibration": calibration, "calibration_url": link}), 200 @maker_bp.route("/api/vision/tasks//recipe/dxf-compare", methods=["POST"]) @login_required @role_required("Maker") def api_dxf_compare_recipe(task_id: int): """Proxy: crea (o prova, save=false) la ricetta DXF compare da camera (B3).""" image = request.files.get("image") dxf = request.files.get("dxf") if image is None or not image.filename or dxf is None or not dxf.filename: return jsonify({"error": True, "detail": _("Servono l'immagine e il file DXF")}), 400 # Solo i campi del contratto B3; i vuoti non si inoltrano (il backend ha i default) data = {} for key in ("station_id", "px_per_mm", "band_mm", "min_points_pct", "polarity", "save"): value = request.form.get(key) if value not in (None, ""): data[key] = value resp = api_client.post( f"/api/vision/tasks/{task_id}/recipe/dxf-compare", data=data, files={ "image": (image.filename, image.read(), image.mimetype or "application/octet-stream"), "dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream"), }, ) if resp.get("error"): return jsonify(resp), resp.get("status_code", 500) return jsonify(resp), 200 # #105: parametri della ricetta DXF gia' salvata sul task, per riaprire il wizard compilato @maker_bp.route("/api/vision/tasks//recipe/dxf-compare/source", methods=["GET"]) @login_required @role_required("Maker") def api_dxf_compare_source(task_id: int): """Proxy: parametri e file della ricetta DXF del task (404 se non ce n'e' una) (#105).""" resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe/dxf-compare/source") # Codice e corpo del backend arrivano com'e': il 404 vuol dire «nessuna ricetta da DXF» if resp.get("error"): return jsonify(resp), resp.get("status_code", 500) return jsonify(resp), 200 # #105: un file della cartella ricetta (DXF, immagine di riferimento) come byte, non JSON @maker_bp.route("/api/vision/tasks//recipe/files/", methods=["GET"]) @login_required @role_required("Maker") def api_vision_recipe_file(task_id: int, path: str): """Proxy: byte di un file della cartella ricetta del task, con content-type del backend (#105).""" # Niente segmenti '..' o backslash: urllib3 normalizzerebbe l'URL fuori da questa rotta del backend if "\\" in path or ".." in path.split("/"): return jsonify({"error": True, "detail": _("File non trovato")}), 404 try: resp = http_requests.get( f"{Config.API_SERVER_URL.rstrip('/')}/api/vision/tasks/{task_id}/recipe/files/{quote(path)}", headers={"X-API-Key": session.get("api_key", "")}, timeout=30, ) except http_requests.RequestException as exc: return jsonify({ "error": True, "detail": _("Servizio non raggiungibile: %(detail)s", detail=str(exc)), }), 502 return Response( resp.content, status=resp.status_code, content_type=resp.headers.get("content-type", "application/octet-stream"), ) # #101: anteprima del DXF appena scelto nel wizard (contorni letti dal vision worker) @maker_bp.route("/api/vision/dxf-preview", methods=["POST"]) @login_required @role_required("Maker") def api_dxf_preview(): """Proxy: anteprima del DXF (contorni, bbox, L x H) prima di creare la ricetta (#101).""" dxf = request.files.get("dxf") if dxf is None or not dxf.filename: return jsonify({"error": True, "detail": _("Serve il file DXF")}), 400 resp = api_client.post( "/api/vision/dxf-preview", files={"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream")}, ) if resp.get("error"): return jsonify(resp), resp.get("status_code", 500) return jsonify(resp), 200 @maker_bp.route("/api/files/", 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/", 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//versions//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//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