Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions backend/middleware/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<Resource> 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."
Comment thread
coderabbitai[bot] marked this conversation as resolved.


class ExceptionLoggingMiddleware:
"""Custom middleware to log unhandled errors.

Expand Down
67 changes: 67 additions & 0 deletions backend/middleware/test_exception.py
Original file line number Diff line number Diff line change
@@ -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."
12 changes: 12 additions & 0 deletions frontend/src/hooks/useExceptionHandler.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading