diff --git a/backend/middleware/exception.py b/backend/middleware/exception.py index f5ea1a6f7c..ce6b1abadf 100644 --- a/backend/middleware/exception.py +++ b/backend/middleware/exception.py @@ -47,12 +47,37 @@ def drf_logging_exc_handler(exc: Exception, context: Any) -> Response | None: return response response: Response | None = exception_handler(exc=exc, context=context) + _enrich_not_found_detail(response=response, context=context) ExceptionLoggingMiddleware.format_exc_and_log( request=request, response=response, exception=exc ) return response +def _enrich_not_found_detail(response: Response | None, context: Any) -> None: + """Replace DRF's generic "Not found." with the resource's name. + + Derives a human label from the view's `queryset` model so every 404 + reads " not found." app-wide, no per-view work. Uses the + static `queryset` attr (not `get_queryset()`) to avoid running view + logic during error handling; views without it keep the generic message. + Override `Meta.verbose_name` to tune a model's label. + """ + if response is None or getattr(response, "status_code", None) != 404: + return + data = getattr(response, "data", None) + if not isinstance(data, dict): + return + model = getattr(getattr(context.get("view"), "queryset", None), "model", None) + if model is None: + return + label = str(model._meta.verbose_name).capitalize() + for err in data.get("errors", []): + # Guard: a raise here would mask the original error with a 500. + if isinstance(err, dict) and err.get("code") == "not_found": + err["detail"] = f"{label} not found." + + class ExceptionLoggingMiddleware: """Custom middleware to log unhandled errors. diff --git a/backend/middleware/test_exception.py b/backend/middleware/test_exception.py new file mode 100644 index 0000000000..217a3aaa36 --- /dev/null +++ b/backend/middleware/test_exception.py @@ -0,0 +1,67 @@ +"""Unit checks for the 404 detail enrichment in the DRF exception handler. + +Pure-logic; uses fakes so no DB or real models are needed. Importing the +handler pulls DRF, which reads settings at import — configure a minimal +settings object when the suite hasn't already, so this runs standalone too. +""" + +import django +from django.conf import settings + +if not settings.configured: + settings.configure(INSTALLED_APPS=[], DATABASES={}) + django.setup() + +from middleware.exception import _enrich_not_found_detail # noqa: E402 + + +class _Meta: + verbose_name = "lookup definition" + + +class _Model: + _meta = _Meta + + +class _QuerySet: + model = _Model + + +class _View: + queryset = _QuerySet + + +class _Resp: + def __init__(self, status_code, data): + self.status_code = status_code + self.data = data + + +def test_404_with_model_uses_verbose_name(): + resp = _Resp(404, {"errors": [{"code": "not_found", "detail": "Not found."}]}) + _enrich_not_found_detail(resp, {"view": _View()}) + assert resp.data["errors"][0]["detail"] == "Lookup definition not found." + + +def test_404_without_queryset_keeps_generic(): + resp = _Resp(404, {"errors": [{"code": "not_found", "detail": "Not found."}]}) + _enrich_not_found_detail(resp, {"view": object()}) + assert resp.data["errors"][0]["detail"] == "Not found." + + +def test_non_404_untouched(): + resp = _Resp(400, {"errors": [{"code": "not_found", "detail": "Not found."}]}) + _enrich_not_found_detail(resp, {"view": _View()}) + assert resp.data["errors"][0]["detail"] == "Not found." + + +def test_none_response_is_safe(): + _enrich_not_found_detail(None, {}) + + +def test_non_dict_error_item_does_not_raise(): + resp = _Resp( + 404, {"errors": ["unexpected", {"code": "not_found", "detail": "Not found."}]} + ) + _enrich_not_found_detail(resp, {"view": _View()}) + assert resp.data["errors"][1]["detail"] == "Lookup definition not found." diff --git a/frontend/src/hooks/useExceptionHandler.jsx b/frontend/src/hooks/useExceptionHandler.jsx index 62917a8f0a..eef7dbfc7b 100644 --- a/frontend/src/hooks/useExceptionHandler.jsx +++ b/frontend/src/hooks/useExceptionHandler.jsx @@ -48,6 +48,18 @@ const useExceptionHandler = () => { // Handle validation errors if (setBackendErrors) { setBackendErrors(err?.response?.data); + // Non-field errors map to no input and would vanish silently; + // surface them as a toast. Field-bound errors render inline. + const nonFieldErrors = (Array.isArray(errors) ? errors : []).filter( + (error) => !error?.attr || error.attr === "non_field_errors", + ); + if (nonFieldErrors.length > 0) { + return alert( + nonFieldErrors + .map((error) => error?.detail || errMessage) + .join(" • "), + ); + } } else { // Handle both single error and array of errors let errorMessage = "Validation error";