|
@@ -14,7 +14,9 @@ from pathlib import Path, PurePosixPath
|
|
|
from urllib.parse import urlparse
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi import FastAPI
|
|
|
-from fastapi.responses import FileResponse
|
|
|
|
|
|
|
+from fastapi.encoders import jsonable_encoder
|
|
|
|
|
+from fastapi.exceptions import RequestValidationError
|
|
|
|
|
+from fastapi.responses import FileResponse, JSONResponse
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
from sqlalchemy import delete, inspect as sa_inspect, or_, select, text, update
|
|
from sqlalchemy import delete, inspect as sa_inspect, or_, select, text, update
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.exc import IntegrityError
|
|
@@ -1498,7 +1500,10 @@ def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None =
|
|
|
if not isinstance(ams_units, list) or not ams_units:
|
|
if not isinstance(ams_units, list) or not ams_units:
|
|
|
return result
|
|
return result
|
|
|
|
|
|
|
|
- # Map global tray id (unit * 4 + tray) → (type, color).
|
|
|
|
|
|
|
+ from backend.app.services.print_scheduler import _global_tray_id
|
|
|
|
|
+
|
|
|
|
|
+ # Map global tray id → (type, color), addressed as the print's ams_mapping
|
|
|
|
|
+ # is: unit * 4 + tray, or the bare unit id for an AMS-HT (from 128).
|
|
|
loaded: dict[int, tuple[str, str]] = {}
|
|
loaded: dict[int, tuple[str, str]] = {}
|
|
|
for unit in ams_units:
|
|
for unit in ams_units:
|
|
|
if not isinstance(unit, dict):
|
|
if not isinstance(unit, dict):
|
|
@@ -1515,10 +1520,13 @@ def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None =
|
|
|
except (TypeError, ValueError):
|
|
except (TypeError, ValueError):
|
|
|
continue
|
|
continue
|
|
|
ttype = (tray.get("tray_type") or "").strip()
|
|
ttype = (tray.get("tray_type") or "").strip()
|
|
|
- tcolor = (tray.get("tray_color") or "").strip().upper()
|
|
|
|
|
|
|
+ # RRGGBBAA from the printer, stored as the 3MF path stores it:
|
|
|
|
|
+ # "#RRGGBB" (the Archives page's swatches and colour filter).
|
|
|
|
|
+ hex_color = (tray.get("tray_color") or "").strip().upper()[:6]
|
|
|
|
|
+ tcolor = f"#{hex_color}" if re.fullmatch(r"[0-9A-F]{6}", hex_color) else ""
|
|
|
if not ttype:
|
|
if not ttype:
|
|
|
continue # Empty / unloaded slot.
|
|
continue # Empty / unloaded slot.
|
|
|
- loaded[unit_id * 4 + tray_id] = (ttype, tcolor)
|
|
|
|
|
|
|
+ loaded[_global_tray_id(unit_id, tray_id)] = (ttype, tcolor)
|
|
|
|
|
|
|
|
if not loaded:
|
|
if not loaded:
|
|
|
return result
|
|
return result
|
|
@@ -3813,7 +3821,7 @@ _fallback_3mf_retry_tasks: dict[int, asyncio.Task] = {}
|
|
|
_fallback_recovery_locks: dict[int, asyncio.Lock] = {}
|
|
_fallback_recovery_locks: dict[int, asyncio.Lock] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def _recover_fallback_archive(archive_id: int, source_3mf: Path, printer_id: int) -> bool:
|
|
|
|
|
|
|
+async def _recover_fallback_archive(archive_id: int, source_3mf: Path, printer_id: int, *, price: bool = True) -> bool:
|
|
|
"""Fill in a no-3MF archive from a 3MF that turned up later.
|
|
"""Fill in a no-3MF archive from a 3MF that turned up later.
|
|
|
|
|
|
|
|
Returns True when the row was upgraded. Safe to call speculatively: it
|
|
Returns True when the row was upgraded. Safe to call speculatively: it
|
|
@@ -3824,11 +3832,17 @@ async def _recover_fallback_archive(archive_id: int, source_3mf: Path, printer_i
|
|
|
"""
|
|
"""
|
|
|
lock = _fallback_recovery_locks.setdefault(printer_id, asyncio.Lock())
|
|
lock = _fallback_recovery_locks.setdefault(printer_id, asyncio.Lock())
|
|
|
async with lock:
|
|
async with lock:
|
|
|
- return await _recover_fallback_archive_locked(archive_id, source_3mf, printer_id)
|
|
|
|
|
|
|
+ return await _recover_fallback_archive_locked(archive_id, source_3mf, printer_id, price=price)
|
|
|
|
|
+
|
|
|
|
|
|
|
|
|
|
+async def _recover_fallback_archive_locked(
|
|
|
|
|
+ archive_id: int, source_3mf: Path, printer_id: int, *, price: bool = True
|
|
|
|
|
+) -> bool:
|
|
|
|
|
+ """The body of :func:`_recover_fallback_archive`, under its per-printer lock.
|
|
|
|
|
|
|
|
-async def _recover_fallback_archive_locked(archive_id: int, source_3mf: Path, printer_id: int) -> bool:
|
|
|
|
|
- """The body of :func:`_recover_fallback_archive`, under its per-printer lock."""
|
|
|
|
|
|
|
+ ``price`` is False when the print's end is being handled: the archive
|
|
|
|
|
+ still reads "printing" then, and its end prices it.
|
|
|
|
|
+ """
|
|
|
import zipfile
|
|
import zipfile
|
|
|
|
|
|
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.archive import PrintArchive
|
|
@@ -3871,6 +3885,19 @@ async def _recover_fallback_archive_locked(archive_id: int, source_3mf: Path, pr
|
|
|
source_3mf,
|
|
source_3mf,
|
|
|
recovered.file_size,
|
|
recovered.file_size,
|
|
|
)
|
|
)
|
|
|
|
|
+ if price and recovered.status == "printing":
|
|
|
|
|
+ # Print start priced it from the default rate, having no 3MF to
|
|
|
|
|
+ # estimate from; now it has one. An ended print is left alone: its
|
|
|
|
|
+ # end prices it. A print Bambuddy didn't dispatch has no start
|
|
|
|
|
+ # plate; the one the archive just read from the 3MF stands in.
|
|
|
|
|
+ _schedule_archive_cost_estimate(
|
|
|
|
|
+ printer_id,
|
|
|
|
|
+ recovered.id,
|
|
|
|
|
+ printer_manager,
|
|
|
|
|
+ ams_mapping=_get_start_ams_mapping(print_data, recovered.id),
|
|
|
|
|
+ plate_id=_get_start_plate_id(recovered.id) or recovered.plate_id,
|
|
|
|
|
+ session_factory=async_session,
|
|
|
|
|
+ )
|
|
|
# `archive_updated`, not `archive_created` — the row was already on the
|
|
# `archive_updated`, not `archive_created` — the row was already on the
|
|
|
# Archives page as an empty card and is now filled in, not new.
|
|
# Archives page as an empty card and is now filled in, not new.
|
|
|
await ws_manager.send_archive_updated(
|
|
await ws_manager.send_archive_updated(
|
|
@@ -3885,7 +3912,7 @@ async def _recover_fallback_archive_locked(archive_id: int, source_3mf: Path, pr
|
|
|
return True
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def try_recover_fallback_archive(printer_id: int, name: str, path: Path) -> bool:
|
|
|
|
|
|
|
+async def try_recover_fallback_archive(printer_id: int, name: str, path: Path, *, price: bool = True) -> bool:
|
|
|
"""Offer a freshly-downloaded 3MF to this printer's running fallback archive.
|
|
"""Offer a freshly-downloaded 3MF to this printer's running fallback archive.
|
|
|
|
|
|
|
|
Called from the paths that pull a 3MF for a print that is already under way
|
|
Called from the paths that pull a 3MF for a print that is already under way
|
|
@@ -3921,7 +3948,7 @@ async def try_recover_fallback_archive(printer_id: int, name: str, path: Path) -
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
- return await _recover_fallback_archive(archive_id, path, printer_id)
|
|
|
|
|
|
|
+ return await _recover_fallback_archive(archive_id, path, printer_id, price=price)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
# Recovery is opportunistic. A failure here must never take down the
|
|
# Recovery is opportunistic. A failure here must never take down the
|
|
|
# caller, which is usually just trying to render a thumbnail.
|
|
# caller, which is usually just trying to render a thumbnail.
|
|
@@ -5465,65 +5492,68 @@ async def on_print_start(printer_id: int, data: dict):
|
|
|
search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
|
|
search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
|
|
|
logger.info("Direct FTP download failed, searching directories for '%s'", search_term)
|
|
logger.info("Direct FTP download failed, searching directories for '%s'", search_term)
|
|
|
search_dirs = ["/cache", "/model", "/data", "/data/Metadata", "/"]
|
|
search_dirs = ["/cache", "/model", "/data", "/data/Metadata", "/"]
|
|
|
|
|
+ # Every folder is listed before one file is picked, so a file named
|
|
|
|
|
+ # for the print in a later folder wins over one that merely
|
|
|
|
|
+ # contains the name in an earlier folder.
|
|
|
|
|
+ listed_paths: list[str] = []
|
|
|
for search_dir in search_dirs:
|
|
for search_dir in search_dirs:
|
|
|
- if downloaded_filename:
|
|
|
|
|
- break
|
|
|
|
|
try:
|
|
try:
|
|
|
dir_files = await list_files_async(
|
|
dir_files = await list_files_async(
|
|
|
printer.ip_address, printer.access_code, search_dir, printer_model=printer.model
|
|
printer.ip_address, printer.access_code, search_dir, printer_model=printer.model
|
|
|
)
|
|
)
|
|
|
- threemf_files = [f.get("name") for f in dir_files if f.get("name", "").endswith(".3mf")]
|
|
|
|
|
- if threemf_files:
|
|
|
|
|
- logger.info(
|
|
|
|
|
- f"Found {len(threemf_files)} 3MF files in {search_dir}: {threemf_files[:5]}{'...' if len(threemf_files) > 5 else ''}"
|
|
|
|
|
- )
|
|
|
|
|
- for f in dir_files:
|
|
|
|
|
- if f.get("is_directory"):
|
|
|
|
|
- continue
|
|
|
|
|
- fname = f.get("name", "")
|
|
|
|
|
- # Normalize both for comparison (spaces and underscores are equivalent)
|
|
|
|
|
- fname_normalized = fname.lower().replace(" ", "_")
|
|
|
|
|
- search_normalized = search_term.replace(" ", "_")
|
|
|
|
|
- if fname.endswith(".3mf") and search_normalized in fname_normalized:
|
|
|
|
|
- logger.info("Found matching file in %s: %s", search_dir, fname)
|
|
|
|
|
- temp_path = app_settings.archive_dir / "temp" / fname
|
|
|
|
|
- temp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
- remote_full_path = posixpath.join(search_dir, fname)
|
|
|
|
|
- if ftp_retry_enabled:
|
|
|
|
|
- downloaded = await with_ftp_retry(
|
|
|
|
|
- download_file_async,
|
|
|
|
|
- printer.ip_address,
|
|
|
|
|
- printer.access_code,
|
|
|
|
|
- remote_full_path,
|
|
|
|
|
- temp_path,
|
|
|
|
|
- timeout=ftp_timeout,
|
|
|
|
|
- socket_timeout=ftp_timeout,
|
|
|
|
|
- printer_model=printer.model,
|
|
|
|
|
- max_retries=ftp_retry_count,
|
|
|
|
|
- retry_delay=ftp_retry_delay,
|
|
|
|
|
- operation_name=f"Download 3MF from {remote_full_path}",
|
|
|
|
|
- cooloff_ip=printer.ip_address,
|
|
|
|
|
- )
|
|
|
|
|
- else:
|
|
|
|
|
- downloaded = await download_file_async(
|
|
|
|
|
- printer.ip_address,
|
|
|
|
|
- printer.access_code,
|
|
|
|
|
- remote_full_path,
|
|
|
|
|
- temp_path,
|
|
|
|
|
- timeout=ftp_timeout,
|
|
|
|
|
- socket_timeout=ftp_timeout,
|
|
|
|
|
- printer_model=printer.model,
|
|
|
|
|
- )
|
|
|
|
|
- if downloaded:
|
|
|
|
|
- downloaded_filename = fname
|
|
|
|
|
- logger.info("Found and downloaded from %s: %s", search_dir, fname)
|
|
|
|
|
- cache_3mf_download(printer_id, fname, temp_path)
|
|
|
|
|
- break
|
|
|
|
|
- # The listing named the file, so it is on the card;
|
|
|
|
|
- # only the transfer failed (#3063).
|
|
|
|
|
- ftp_transfer_failed = True
|
|
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.debug("Failed to list %s: %s", search_dir, e)
|
|
logger.debug("Failed to list %s: %s", search_dir, e)
|
|
|
|
|
+ continue
|
|
|
|
|
+ threemf_files = [f.get("name") for f in dir_files if f.get("name", "").endswith(".3mf")]
|
|
|
|
|
+ if threemf_files:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ f"Found {len(threemf_files)} 3MF files in {search_dir}: {threemf_files[:5]}{'...' if len(threemf_files) > 5 else ''}"
|
|
|
|
|
+ )
|
|
|
|
|
+ listed_paths += [
|
|
|
|
|
+ posixpath.join(search_dir, f.get("name", "")) for f in dir_files if not f.get("is_directory")
|
|
|
|
|
+ ]
|
|
|
|
|
+ for remote_full_path in _3mf_files_for_print(listed_paths, search_term):
|
|
|
|
|
+ fname = posixpath.basename(remote_full_path)
|
|
|
|
|
+ logger.info("Found matching file: %s", remote_full_path)
|
|
|
|
|
+ temp_path = app_settings.archive_dir / "temp" / fname
|
|
|
|
|
+ temp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ try:
|
|
|
|
|
+ if ftp_retry_enabled:
|
|
|
|
|
+ downloaded = await with_ftp_retry(
|
|
|
|
|
+ download_file_async,
|
|
|
|
|
+ printer.ip_address,
|
|
|
|
|
+ printer.access_code,
|
|
|
|
|
+ remote_full_path,
|
|
|
|
|
+ temp_path,
|
|
|
|
|
+ timeout=ftp_timeout,
|
|
|
|
|
+ socket_timeout=ftp_timeout,
|
|
|
|
|
+ printer_model=printer.model,
|
|
|
|
|
+ max_retries=ftp_retry_count,
|
|
|
|
|
+ retry_delay=ftp_retry_delay,
|
|
|
|
|
+ operation_name=f"Download 3MF from {remote_full_path}",
|
|
|
|
|
+ cooloff_ip=printer.ip_address,
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ downloaded = await download_file_async(
|
|
|
|
|
+ printer.ip_address,
|
|
|
|
|
+ printer.access_code,
|
|
|
|
|
+ remote_full_path,
|
|
|
|
|
+ temp_path,
|
|
|
|
|
+ timeout=ftp_timeout,
|
|
|
|
|
+ socket_timeout=ftp_timeout,
|
|
|
|
|
+ printer_model=printer.model,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.debug("Failed to download %s: %s", remote_full_path, e)
|
|
|
|
|
+ continue
|
|
|
|
|
+ if downloaded:
|
|
|
|
|
+ downloaded_filename = fname
|
|
|
|
|
+ logger.info("Found and downloaded: %s", remote_full_path)
|
|
|
|
|
+ cache_3mf_download(printer_id, fname, temp_path)
|
|
|
|
|
+ break
|
|
|
|
|
+ # The listing named the file, so it is on the card;
|
|
|
|
|
+ # only the transfer failed (#3063).
|
|
|
|
|
+ ftp_transfer_failed = True
|
|
|
|
|
|
|
|
# Validate the downloaded 3MF actually matches the plate that's running
|
|
# Validate the downloaded 3MF actually matches the plate that's running
|
|
|
# (#1204): subtask_name lags across consecutive plates of the same model,
|
|
# (#1204): subtask_name lags across consecutive plates of the same model,
|
|
@@ -7636,6 +7666,28 @@ def _normalise_subtask_name(name: str) -> str:
|
|
|
return name.strip().replace(" ", "_").strip("_").casefold()
|
|
return name.strip().replace(" ", "_").strip("_").casefold()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _3mf_files_for_print(paths: list[str], search_term: str) -> list[str]:
|
|
|
|
|
+ """The 3MF files of the printer's folder listings that are this print's.
|
|
|
|
|
+
|
|
|
|
|
+ A file of the print's own name wins, in whichever folder. Only without one
|
|
|
|
|
+ is a file that merely contains the name taken, and then only when it is
|
|
|
|
|
+ the one such file: "Cube_large.3mf" is not "Cube"'s file, and of several
|
|
|
|
|
+ candidates none can be told apart. Paths keep the listings' order.
|
|
|
|
|
+ """
|
|
|
|
|
+ threemf = [path for path in paths if path.lower().endswith(".3mf")]
|
|
|
|
|
+ exact = [path for path in threemf if _subtask_names_match(_subtask_name_from_filename(path), search_term)]
|
|
|
|
|
+ if exact:
|
|
|
|
|
+ return exact
|
|
|
|
|
+ term = _normalise_subtask_name(search_term)
|
|
|
|
|
+ partial = [path for path in threemf if term in _normalise_subtask_name(PurePosixPath(path).name)]
|
|
|
|
|
+ if len(partial) > 1:
|
|
|
|
|
+ logging.getLogger(__name__).info(
|
|
|
|
|
+ "Several 3MF files contain %r and none is named for it: %s", search_term, partial
|
|
|
|
|
+ )
|
|
|
|
|
+ return []
|
|
|
|
|
+ return partial
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _subtask_names_match(expected: str, observed: str) -> bool:
|
|
def _subtask_names_match(expected: str, observed: str) -> bool:
|
|
|
"""Whether two subtask names describe the same print.
|
|
"""Whether two subtask names describe the same print.
|
|
|
|
|
|
|
@@ -7740,7 +7792,9 @@ async def _recover_fallback_from_cache_before_eviction(printer_id: int, data: di
|
|
|
for name in names:
|
|
for name in names:
|
|
|
try:
|
|
try:
|
|
|
cached = get_cached_3mf(printer_id, name)
|
|
cached = get_cached_3mf(printer_id, name)
|
|
|
- if cached and await try_recover_fallback_archive(printer_id, name, cached):
|
|
|
|
|
|
|
+ # The print has ended: its completion prices the archive, so no
|
|
|
|
|
+ # print-start estimate is scheduled to race it.
|
|
|
|
|
+ if cached and await try_recover_fallback_archive(printer_id, name, cached, price=False):
|
|
|
return
|
|
return
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.debug("[RECOVER] Pre-eviction recovery for %s failed: %s", name, e)
|
|
logger.debug("[RECOVER] Pre-eviction recovery for %s failed: %s", name, e)
|
|
@@ -8003,22 +8057,35 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
async with async_session() as db:
|
|
async with async_session() as db:
|
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.archive import PrintArchive
|
|
|
|
|
|
|
|
- # Try matching by subtask_name (stored as print_name) first
|
|
|
|
|
|
|
+ # Try matching by subtask_name (stored as print_name) first. The
|
|
|
|
|
+ # same name rule as the queue's completion check: a name inside
|
|
|
|
|
+ # another ("Clip" in "Cable Clip") is a different print, and the
|
|
|
|
|
+ # printer's underscores for spaces and truncation still match.
|
|
|
if subtask_name:
|
|
if subtask_name:
|
|
|
- result = await db.execute(
|
|
|
|
|
|
|
+ printing = await db.scalars(
|
|
|
select(PrintArchive)
|
|
select(PrintArchive)
|
|
|
.where(PrintArchive.printer_id == printer_id)
|
|
.where(PrintArchive.printer_id == printer_id)
|
|
|
.where(PrintArchive.status == "printing")
|
|
.where(PrintArchive.status == "printing")
|
|
|
- .where(
|
|
|
|
|
- or_(
|
|
|
|
|
- PrintArchive.print_name.ilike(f"%{subtask_name}%"),
|
|
|
|
|
- PrintArchive.filename.ilike(f"%{subtask_name}%"),
|
|
|
|
|
- )
|
|
|
|
|
- )
|
|
|
|
|
.order_by(PrintArchive.created_at.desc())
|
|
.order_by(PrintArchive.created_at.desc())
|
|
|
- .limit(1)
|
|
|
|
|
)
|
|
)
|
|
|
- archive = result.scalar_one_or_none()
|
|
|
|
|
|
|
+ # A print started from the printer's screen reports its file
|
|
|
|
|
+ # name, extensions included, as the subtask (#3009).
|
|
|
|
|
+ reported = {subtask_name, _subtask_name_from_filename(subtask_name)}
|
|
|
|
|
+ archive = next(
|
|
|
|
|
+ (
|
|
|
|
|
+ candidate
|
|
|
|
|
+ for candidate in printing
|
|
|
|
|
+ if any(
|
|
|
|
|
+ (candidate.print_name and _subtask_names_match(candidate.print_name, name))
|
|
|
|
|
+ or (
|
|
|
|
|
+ candidate.filename
|
|
|
|
|
+ and _subtask_names_match(_subtask_name_from_filename(candidate.filename), name)
|
|
|
|
|
+ )
|
|
|
|
|
+ for name in reported
|
|
|
|
|
+ )
|
|
|
|
|
+ ),
|
|
|
|
|
+ None,
|
|
|
|
|
+ )
|
|
|
if archive:
|
|
if archive:
|
|
|
archive_id = archive.id
|
|
archive_id = archive.id
|
|
|
logger.info("Found archive %s by subtask_name match: %s", archive_id, subtask_name)
|
|
logger.info("Found archive %s by subtask_name match: %s", archive_id, subtask_name)
|
|
@@ -11036,6 +11103,28 @@ app = FastAPI(
|
|
|
app.add_middleware(RefusedHeaderResponseMask)
|
|
app.add_middleware(RefusedHeaderResponseMask)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _json_safe(value):
|
|
|
|
|
+ """The validation errors with every non-finite number written as text."""
|
|
|
|
|
+ if isinstance(value, float) and not math.isfinite(value):
|
|
|
|
|
+ return str(value)
|
|
|
|
|
+ if isinstance(value, dict):
|
|
|
|
|
+ return {key: _json_safe(item) for key, item in value.items()}
|
|
|
|
|
+ if isinstance(value, list | tuple):
|
|
|
|
|
+ return [_json_safe(item) for item in value]
|
|
|
|
|
+ return value
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.exception_handler(RequestValidationError)
|
|
|
|
|
+async def _validation_error(_request, exc: RequestValidationError) -> JSONResponse:
|
|
|
|
|
+ """FastAPI's own 422, except that it survives a non-finite number.
|
|
|
|
|
+
|
|
|
|
|
+ A body may carry ``Infinity`` or ``NaN`` (Python's JSON reader accepts
|
|
|
|
|
+ them), and the default handler echoes the input back in its errors,
|
|
|
|
|
+ which JSON cannot hold: the request ended in an error instead of a 422.
|
|
|
|
|
+ """
|
|
|
|
|
+ return JSONResponse(status_code=422, content={"detail": _json_safe(jsonable_encoder(exc.errors()))})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
# =============================================================================
|
|
# =============================================================================
|
|
|
# Authentication Middleware - Secures ALL API routes by default
|
|
# Authentication Middleware - Secures ALL API routes by default
|
|
|
# =============================================================================
|
|
# =============================================================================
|