hjtdev-appkit 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- appkit/__init__.py +39 -0
- appkit/apps.py +33 -0
- appkit/cache.py +222 -0
- appkit/checks.py +513 -0
- appkit/conf.py +71 -0
- appkit/crypto.py +102 -0
- appkit/dates.py +183 -0
- appkit/exceptions.py +158 -0
- appkit/files.py +302 -0
- appkit/locale/fa/LC_MESSAGES/django.mo +0 -0
- appkit/locale/fa/LC_MESSAGES/django.po +29 -0
- appkit/media.py +102 -0
- appkit/mixins.py +68 -0
- appkit/money.py +69 -0
- appkit/net.py +115 -0
- appkit/pagination.py +30 -0
- appkit/permissions.py +48 -0
- appkit/py.typed +0 -0
- appkit/request_id.py +96 -0
- appkit/testing.py +259 -0
- appkit/text.py +62 -0
- appkit/throttling.py +41 -0
- appkit/validation.py +195 -0
- hjtdev_appkit-2.0.0.dist-info/METADATA +17 -0
- hjtdev_appkit-2.0.0.dist-info/RECORD +28 -0
- hjtdev_appkit-2.0.0.dist-info/WHEEL +5 -0
- hjtdev_appkit-2.0.0.dist-info/licenses/LICENSE +21 -0
- hjtdev_appkit-2.0.0.dist-info/top_level.txt +1 -0
appkit/crypto.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Fernet symmetric encryption primitive taking its key at construction time.
|
|
2
|
+
|
|
3
|
+
Requires the ``crypto`` extra (``hjtdev-appkit[crypto]``). A missing extra must fail with an
|
|
4
|
+
actionable message, never a bare ImportError — import ``cryptography`` lazily inside the
|
|
5
|
+
function/method that needs it, wrapped in try/except ImportError, re-raised naming the exact
|
|
6
|
+
fix (``Install with: uv add "hjtdev-appkit[crypto]"`` / ``pip install "hjtdev-appkit[crypto]"``).
|
|
7
|
+
This error path is itself unit-tested (docs/CONTRACT.md §9).
|
|
8
|
+
|
|
9
|
+
appkit never reads ``settings.FERNET_KEY`` or any other Django setting for this — the key is
|
|
10
|
+
always a call-time argument. This is the resolution to the tools/-vs-appkit tension
|
|
11
|
+
(docs/CONTRACT.md §3): field-level crypto stays in ``tools/crypto.py`` permanently, wrapping the
|
|
12
|
+
HOST's ``FERNET_KEY``; an app declaring ``hjtdev-appkit[crypto]`` builds a ``Cipher`` from its OWN
|
|
13
|
+
documented ``.env`` key. appkit therefore requires no ``.env`` key and no settings key for
|
|
14
|
+
encryption, under any install combination.
|
|
15
|
+
|
|
16
|
+
Public surface (docs/CONTRACT.md §2.5):
|
|
17
|
+
|
|
18
|
+
class Cipher:
|
|
19
|
+
def __init__(self, key: str | bytes) -> None: ... # raises ImproperlyConfigured on a
|
|
20
|
+
# bad key
|
|
21
|
+
def encrypt(self, value: str) -> str: ...
|
|
22
|
+
def decrypt(self, token: str) -> str: ... # raises
|
|
23
|
+
# cryptography.fernet.InvalidToken
|
|
24
|
+
|
|
25
|
+
def generate_key() -> str: ...
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import TYPE_CHECKING
|
|
31
|
+
|
|
32
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from cryptography.fernet import Fernet
|
|
36
|
+
|
|
37
|
+
__all__ = ["Cipher", "generate_key"]
|
|
38
|
+
|
|
39
|
+
_INSTALL_HINT = (
|
|
40
|
+
'Install with: uv add "hjtdev-appkit[crypto]" (or: pip install "hjtdev-appkit[crypto]")'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fernet_class() -> type[Fernet]:
|
|
45
|
+
"""Lazily imports `cryptography.fernet.Fernet`, behind the `crypto` extra.
|
|
46
|
+
|
|
47
|
+
A missing extra must fail with an actionable message, never a bare `ImportError` three
|
|
48
|
+
frames deep — this path is unit-tested by simulating the import failure.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
from cryptography.fernet import Fernet
|
|
52
|
+
except ImportError as exc:
|
|
53
|
+
raise ImportError(
|
|
54
|
+
f"appkit.crypto requires the 'cryptography' package. {_INSTALL_HINT}"
|
|
55
|
+
) from exc
|
|
56
|
+
return Fernet
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Cipher:
|
|
60
|
+
"""Fernet symmetric encryption, keyed at construction — never from Django settings.
|
|
61
|
+
|
|
62
|
+
A host's own encryption key is its own documented `.env` key; this class only ever wraps
|
|
63
|
+
whatever key its caller passes in.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, key: str | bytes) -> None:
|
|
67
|
+
"""Builds the underlying `Fernet` cipher from `key`.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ImportError: if the `crypto` extra isn't installed.
|
|
71
|
+
ImproperlyConfigured: if `key` isn't a valid Fernet key (44-byte urlsafe-base64) —
|
|
72
|
+
never the raw `cryptography` `ValueError`/`TypeError`/`binascii.Error` — naming
|
|
73
|
+
`generate_key()` as the fix.
|
|
74
|
+
"""
|
|
75
|
+
fernet_cls = _fernet_class()
|
|
76
|
+
try:
|
|
77
|
+
self._fernet = fernet_cls(key)
|
|
78
|
+
except (TypeError, ValueError) as exc:
|
|
79
|
+
raise ImproperlyConfigured(
|
|
80
|
+
"appkit.crypto.Cipher() was given a key that is not a valid Fernet key. "
|
|
81
|
+
"Generate one with appkit.crypto.generate_key()."
|
|
82
|
+
) from exc
|
|
83
|
+
|
|
84
|
+
def encrypt(self, value: str) -> str:
|
|
85
|
+
"""Returns a URL-safe token string. Never raises for any `str` input."""
|
|
86
|
+
return self._fernet.encrypt(value.encode()).decode()
|
|
87
|
+
|
|
88
|
+
def decrypt(self, token: str) -> str:
|
|
89
|
+
"""Decrypts a token produced by `encrypt`.
|
|
90
|
+
|
|
91
|
+
Raises `cryptography.fernet.InvalidToken` for a tampered, expired (if a TTL was used),
|
|
92
|
+
or wrong-key token — never silently returns garbage.
|
|
93
|
+
"""
|
|
94
|
+
return self._fernet.decrypt(token.encode()).decode()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def generate_key() -> str:
|
|
98
|
+
"""Thin wrapper over `Fernet.generate_key().decode()` — provisions a key for `Cipher`
|
|
99
|
+
without a caller needing to `import cryptography` directly.
|
|
100
|
+
"""
|
|
101
|
+
fernet_cls = _fernet_class()
|
|
102
|
+
return fernet_cls.generate_key().decode()
|
appkit/dates.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Gregorian <-> Jalali conversion, formatting, and parsing using stdlib types only.
|
|
2
|
+
|
|
3
|
+
No third-party type in any public signature — ``jdatetime``/``jalali-core`` stay internal
|
|
4
|
+
(docs/CONTRACT.md §9). A major-version bump in either can never force an appkit major bump on
|
|
5
|
+
its own.
|
|
6
|
+
|
|
7
|
+
Public surface (docs/CONTRACT.md §2.13):
|
|
8
|
+
|
|
9
|
+
def to_jalali(value: date | datetime) -> tuple[int, int, int]: ...
|
|
10
|
+
def from_jalali(year: int, month: int, day: int) -> date: ... # raises ValueError
|
|
11
|
+
def format_jalali(value: date | datetime, fmt: str = "%Y/%m/%d") -> str: ...
|
|
12
|
+
def parse_jalali(value: str, fmt: str = "%Y/%m/%d") -> date: ... # raises ValueError
|
|
13
|
+
|
|
14
|
+
Golden vectors verified against tests/fixtures/jalali-vectors.json (docs/CONTRACT.md §19):
|
|
15
|
+
every leap year in the 33-year Jalali cycle plus adjacent non-leap years, Esfand 29 vs. 30 in
|
|
16
|
+
both leap and non-leap years, Nowruz across several years, the 31-day/30-day month-length
|
|
17
|
+
boundary, Gregorian leap years including century years, and dates well outside the near present
|
|
18
|
+
— every vector round-tripped in both directions.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import datetime as dt
|
|
24
|
+
import re
|
|
25
|
+
from typing import Final
|
|
26
|
+
|
|
27
|
+
import jdatetime
|
|
28
|
+
from django.utils import timezone
|
|
29
|
+
|
|
30
|
+
from appkit.text import to_english_digits
|
|
31
|
+
|
|
32
|
+
__all__ = ["format_jalali", "from_jalali", "parse_jalali", "to_jalali"]
|
|
33
|
+
|
|
34
|
+
# docs/CONTRACT.md §18: format_jalali/parseJalali support only the numeric directives below —
|
|
35
|
+
# no month names ship in v1.0.0 (removes an entire Persian-spelling-drift divergence class
|
|
36
|
+
# between the two halves). An unrecognised directive raises, mirroring strftime's own failure
|
|
37
|
+
# mode, which stdlib strftime itself does NOT do for %B/%A-style directives it doesn't
|
|
38
|
+
# recognise — this is why format_jalali/parse_jalali can't simply delegate to strftime/strptime.
|
|
39
|
+
_SUPPORTED_DIRECTIVES: Final[frozenset[str]] = frozenset("YmdHMS%")
|
|
40
|
+
|
|
41
|
+
_DIRECTIVE_REGEX: Final[dict[str, str]] = {
|
|
42
|
+
"Y": r"\d{1,4}",
|
|
43
|
+
"m": r"\d{1,2}",
|
|
44
|
+
"d": r"\d{1,2}",
|
|
45
|
+
"H": r"\d{1,2}",
|
|
46
|
+
"M": r"\d{1,2}",
|
|
47
|
+
"S": r"\d{1,2}",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def to_jalali(value: dt.date | dt.datetime) -> tuple[int, int, int]:
|
|
52
|
+
"""Returns `(year, month, day)` for the Jalali calendar date corresponding to `value`.
|
|
53
|
+
|
|
54
|
+
**The timezone rule (docs/CONTRACT.md §18), implemented exactly as stated, not re-decided:**
|
|
55
|
+
an *aware* `datetime` is localised via `django.utils.timezone.localtime()` (Django's own
|
|
56
|
+
`TIME_ZONE` setting) before the Jalali date is extracted — a datetime at `23:30 UTC` may
|
|
57
|
+
already be tomorrow in `Asia/Tehran`. A *naive* `datetime` is treated as already-local
|
|
58
|
+
(`localtime()` itself raises on a naive value, so it's simply not called). A plain `date`
|
|
59
|
+
has no timezone component and is converted directly.
|
|
60
|
+
|
|
61
|
+
`datetime` is checked before `date` — `datetime` is a subclass of `date`, so the reverse
|
|
62
|
+
order would silently treat every `datetime` as a plain, timezone-naive date. Never raises
|
|
63
|
+
for any valid `date`/`datetime`.
|
|
64
|
+
"""
|
|
65
|
+
if isinstance(value, dt.datetime):
|
|
66
|
+
localized = timezone.localtime(value) if timezone.is_aware(value) else value
|
|
67
|
+
gregorian_date = localized.date()
|
|
68
|
+
else:
|
|
69
|
+
gregorian_date = value
|
|
70
|
+
jalali_date = jdatetime.date.fromgregorian(date=gregorian_date)
|
|
71
|
+
return jalali_date.year, jalali_date.month, jalali_date.day
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def from_jalali(year: int, month: int, day: int) -> dt.date:
|
|
75
|
+
"""The inverse of `to_jalali`.
|
|
76
|
+
|
|
77
|
+
Raises `ValueError` for an invalid Jalali calendar date (day 31 in a 30-day Jalali month,
|
|
78
|
+
an invalid Esfand 30) — the same exception shape `datetime.date(...)` itself raises for an
|
|
79
|
+
invalid Gregorian date, so callers don't need a Jalali-specific except clause.
|
|
80
|
+
"""
|
|
81
|
+
jalali_date = jdatetime.date(year, month, day) # raises ValueError on an invalid date
|
|
82
|
+
result: dt.date = jalali_date.togregorian()
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _time_components(value: dt.date | dt.datetime) -> tuple[int, int, int]:
|
|
87
|
+
if not isinstance(value, dt.datetime):
|
|
88
|
+
return 0, 0, 0
|
|
89
|
+
localized = timezone.localtime(value) if timezone.is_aware(value) else value
|
|
90
|
+
return localized.hour, localized.minute, localized.second
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def format_jalali(value: dt.date | dt.datetime, fmt: str = "%Y/%m/%d") -> str:
|
|
94
|
+
"""`strftime`-style formatting of `value`'s Jalali representation.
|
|
95
|
+
|
|
96
|
+
Supports only `%Y %m %d %H %M %S %%` (docs/CONTRACT.md §18 — no month names in v1.0.0).
|
|
97
|
+
Never raises for a valid `date`/`datetime` input; an unsupported directive raises
|
|
98
|
+
`ValueError`, mirroring stdlib `strftime`'s own failure mode for a malformed format string.
|
|
99
|
+
"""
|
|
100
|
+
year, month, day = to_jalali(value)
|
|
101
|
+
hour, minute, second = _time_components(value)
|
|
102
|
+
substitutions = {
|
|
103
|
+
"Y": f"{year:04d}",
|
|
104
|
+
"m": f"{month:02d}",
|
|
105
|
+
"d": f"{day:02d}",
|
|
106
|
+
"H": f"{hour:02d}",
|
|
107
|
+
"M": f"{minute:02d}",
|
|
108
|
+
"S": f"{second:02d}",
|
|
109
|
+
"%": "%",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
result: list[str] = []
|
|
113
|
+
i = 0
|
|
114
|
+
while i < len(fmt):
|
|
115
|
+
char = fmt[i]
|
|
116
|
+
if char != "%":
|
|
117
|
+
result.append(char)
|
|
118
|
+
i += 1
|
|
119
|
+
continue
|
|
120
|
+
if i + 1 >= len(fmt):
|
|
121
|
+
raise ValueError(f"appkit.dates.format_jalali: dangling '%' at end of format {fmt!r}")
|
|
122
|
+
directive = fmt[i + 1]
|
|
123
|
+
if directive not in _SUPPORTED_DIRECTIVES:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"appkit.dates.format_jalali: unsupported format directive '%{directive}' in "
|
|
126
|
+
f"{fmt!r}"
|
|
127
|
+
)
|
|
128
|
+
result.append(substitutions[directive])
|
|
129
|
+
i += 2
|
|
130
|
+
return "".join(result)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _compile_format(fmt: str) -> re.Pattern[str]:
|
|
134
|
+
"""Builds a matching regex for `fmt`, one named group per first occurrence of a directive.
|
|
135
|
+
|
|
136
|
+
A directive repeated in `fmt` gets a non-capturing group on its second+ occurrence — Python
|
|
137
|
+
regex forbids duplicate group names, and there's no meaningful way to prefer one occurrence
|
|
138
|
+
over another for parsing anyway.
|
|
139
|
+
"""
|
|
140
|
+
parts: list[str] = []
|
|
141
|
+
seen: set[str] = set()
|
|
142
|
+
i = 0
|
|
143
|
+
while i < len(fmt):
|
|
144
|
+
char = fmt[i]
|
|
145
|
+
if char != "%":
|
|
146
|
+
parts.append(re.escape(char))
|
|
147
|
+
i += 1
|
|
148
|
+
continue
|
|
149
|
+
if i + 1 >= len(fmt):
|
|
150
|
+
raise ValueError(f"appkit.dates.parse_jalali: dangling '%' at end of format {fmt!r}")
|
|
151
|
+
directive = fmt[i + 1]
|
|
152
|
+
if directive == "%":
|
|
153
|
+
parts.append(re.escape("%"))
|
|
154
|
+
elif directive in _DIRECTIVE_REGEX:
|
|
155
|
+
body = _DIRECTIVE_REGEX[directive]
|
|
156
|
+
parts.append(f"(?:{body})" if directive in seen else f"(?P<{directive}>{body})")
|
|
157
|
+
seen.add(directive)
|
|
158
|
+
else:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"appkit.dates.parse_jalali: unsupported format directive '%{directive}' in {fmt!r}"
|
|
161
|
+
)
|
|
162
|
+
i += 2
|
|
163
|
+
return re.compile("".join(parts))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_jalali(value: str, fmt: str = "%Y/%m/%d") -> dt.date:
|
|
167
|
+
"""The inverse of `format_jalali`.
|
|
168
|
+
|
|
169
|
+
Runs `to_english_digits` internally first — Persian-keyboard input is the common real-world
|
|
170
|
+
case for a date typed by a user, not pasted. Raises `ValueError` for a string that doesn't
|
|
171
|
+
match `fmt`, or that names an invalid Jalali date — never returns a best-guess/partial
|
|
172
|
+
result. A directive omitted from `fmt` (e.g. `fmt="%Y"` alone) defaults to `1` for month/day.
|
|
173
|
+
"""
|
|
174
|
+
pattern = _compile_format(fmt)
|
|
175
|
+
match = pattern.fullmatch(to_english_digits(value))
|
|
176
|
+
if match is None:
|
|
177
|
+
raise ValueError(f"appkit.dates.parse_jalali: {value!r} does not match format {fmt!r}")
|
|
178
|
+
|
|
179
|
+
groups = match.groupdict()
|
|
180
|
+
year = int(groups["Y"]) if groups.get("Y") is not None else 1
|
|
181
|
+
month = int(groups["m"]) if groups.get("m") is not None else 1
|
|
182
|
+
day = int(groups["d"]) if groups.get("d") is not None else 1
|
|
183
|
+
return from_jalali(year, month, day) # raises ValueError for an invalid Jalali date
|
appkit/exceptions.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""The single DRF exception handler producing the standard error envelope.
|
|
2
|
+
|
|
3
|
+
Envelope shape, verbatim (docs/CONTRACT.md §1)::
|
|
4
|
+
|
|
5
|
+
{"error": {"code": "validation_error", "message": "...", "details": {}, "request_id": "..."}}
|
|
6
|
+
|
|
7
|
+
``details`` is always present (``{}`` when nothing is field-level). ``request_id`` is the same
|
|
8
|
+
correlation ID :data:`appkit.request_id.request_id_var` carries. Headers DRF already sets
|
|
9
|
+
(``Retry-After`` on ``throttled``, ``WWW-Authenticate`` on ``not_authenticated``/
|
|
10
|
+
``authentication_failed``) are untouched — the handler only ever rewrites ``response.data``.
|
|
11
|
+
|
|
12
|
+
The code set is TEN, not nine (docs/CONTRACT.md §1's documented drift correction) — ``"error"``
|
|
13
|
+
is the documented catch-all for any ``APIException`` DRF resolved to a response but that isn't
|
|
14
|
+
one of the other nine specific types. For ``"error"``, the HTTP status is authoritative, not the
|
|
15
|
+
code.
|
|
16
|
+
|
|
17
|
+
Public surface, implemented in a later phase:
|
|
18
|
+
|
|
19
|
+
ERROR_CODES: Final[tuple[str, ...]]
|
|
20
|
+
The ten codes, in this exact order:
|
|
21
|
+
validation_error, parse_error, not_authenticated, authentication_failed,
|
|
22
|
+
permission_denied, not_found, method_not_allowed, throttled, server_error, error.
|
|
23
|
+
Pinned against tests/fixtures/error-codes.json (docs/CONTRACT.md §19), not hand-verified
|
|
24
|
+
against the frontend's ApiErrorCode union directly.
|
|
25
|
+
|
|
26
|
+
standard_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None
|
|
27
|
+
A plain Django Http404/PermissionDenied is converted to its DRF equivalent before code
|
|
28
|
+
lookup. An unhandled exception (DRF's handler returns None) is logged via
|
|
29
|
+
logger.exception before being turned into a server_error envelope. Three own
|
|
30
|
+
user-facing strings — "Validation failed.", "Request failed.", "Internal server
|
|
31
|
+
error." — are wrapped in gettext_lazy.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
from typing import Any, Final
|
|
38
|
+
|
|
39
|
+
from django.conf import settings
|
|
40
|
+
from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
|
|
41
|
+
from django.http import Http404
|
|
42
|
+
from django.utils.translation import gettext_lazy as _
|
|
43
|
+
from rest_framework import exceptions as drf_exceptions
|
|
44
|
+
from rest_framework.response import Response
|
|
45
|
+
from rest_framework.views import exception_handler as drf_exception_handler
|
|
46
|
+
|
|
47
|
+
from appkit.request_id import request_id_var
|
|
48
|
+
|
|
49
|
+
__all__ = ["ERROR_CODES", "standard_exception_handler"]
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
#: The ten codes, in the exact order given in docs/CONTRACT.md §1 — pinned against
|
|
54
|
+
#: tests/fixtures/error-codes.json rather than hand-verified against the frontend's
|
|
55
|
+
#: ApiErrorCode union directly (docs/CONTRACT.md §19).
|
|
56
|
+
ERROR_CODES: Final[tuple[str, ...]] = (
|
|
57
|
+
"validation_error",
|
|
58
|
+
"parse_error",
|
|
59
|
+
"not_authenticated",
|
|
60
|
+
"authentication_failed",
|
|
61
|
+
"permission_denied",
|
|
62
|
+
"not_found",
|
|
63
|
+
"method_not_allowed",
|
|
64
|
+
"throttled",
|
|
65
|
+
"server_error",
|
|
66
|
+
"error",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Ordered most-specific-first: several of these subclass one another, so isinstance order
|
|
70
|
+
# matters — this is a straight port of the scaffold's list (docs/CONTRACT.md §1, §2.3).
|
|
71
|
+
_CODE_BY_EXCEPTION: list[tuple[type[Exception], str]] = [
|
|
72
|
+
(drf_exceptions.ValidationError, "validation_error"),
|
|
73
|
+
(drf_exceptions.ParseError, "parse_error"),
|
|
74
|
+
(drf_exceptions.NotAuthenticated, "not_authenticated"),
|
|
75
|
+
(drf_exceptions.AuthenticationFailed, "authentication_failed"),
|
|
76
|
+
(drf_exceptions.PermissionDenied, "permission_denied"),
|
|
77
|
+
(drf_exceptions.NotFound, "not_found"),
|
|
78
|
+
(drf_exceptions.MethodNotAllowed, "method_not_allowed"),
|
|
79
|
+
(drf_exceptions.Throttled, "throttled"),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _code_for(exc: Exception) -> str:
|
|
84
|
+
for exc_type, code in _CODE_BY_EXCEPTION:
|
|
85
|
+
if isinstance(exc, exc_type):
|
|
86
|
+
return code
|
|
87
|
+
return "error" # the documented catch-all — some other APIException DRF already resolved
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _message_and_details(data: Any, *, code: str) -> tuple[str, dict[str, Any]]:
|
|
91
|
+
"""Splits DRF's raw `response.data` into a flat message plus a details dict.
|
|
92
|
+
|
|
93
|
+
A nested per-field dict (a serializer's validation errors) is passed through as `details`
|
|
94
|
+
intact — the naive flat-message collapse used below must not fire for this case, or which
|
|
95
|
+
field each message belongs to is lost.
|
|
96
|
+
"""
|
|
97
|
+
if isinstance(data, dict) and set(data) == {"detail"}:
|
|
98
|
+
return str(data["detail"]), {}
|
|
99
|
+
if isinstance(data, list):
|
|
100
|
+
return "; ".join(str(item) for item in data), {"non_field_errors": data}
|
|
101
|
+
if isinstance(data, dict):
|
|
102
|
+
message = (
|
|
103
|
+
str(_("Validation failed."))
|
|
104
|
+
if code == "validation_error"
|
|
105
|
+
else str(_("Request failed."))
|
|
106
|
+
)
|
|
107
|
+
return message, data
|
|
108
|
+
return str(data), {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def standard_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None:
|
|
112
|
+
"""DRF `EXCEPTION_HANDLER` producing the envelope described in this module's docstring.
|
|
113
|
+
|
|
114
|
+
Delegates to DRF's own `exception_handler` first and rewrites only `response.data` —
|
|
115
|
+
never rebuilds the `Response` from scratch — so headers DRF already set (`Retry-After`
|
|
116
|
+
on a throttled response, `WWW-Authenticate` on a 401) survive untouched.
|
|
117
|
+
"""
|
|
118
|
+
# DRF's own exception_handler converts a plain Django Http404/PermissionDenied into its
|
|
119
|
+
# DRF equivalent internally, on a *new* exception object it builds and discards — it never
|
|
120
|
+
# hands that conversion back to us. Without redoing it here, _code_for(exc) below would see
|
|
121
|
+
# the original Http404/PermissionDenied, match nothing, and fall through to "error".
|
|
122
|
+
if isinstance(exc, Http404):
|
|
123
|
+
exc = drf_exceptions.NotFound(*exc.args)
|
|
124
|
+
elif isinstance(exc, DjangoPermissionDenied):
|
|
125
|
+
exc = drf_exceptions.PermissionDenied(*exc.args)
|
|
126
|
+
|
|
127
|
+
response = drf_exception_handler(exc, context)
|
|
128
|
+
|
|
129
|
+
if response is None:
|
|
130
|
+
# DRF returns None for anything that isn't an APIException/Http404/PermissionDenied —
|
|
131
|
+
# a genuinely unhandled exception. That would otherwise propagate to Django's own error
|
|
132
|
+
# handling (which is what triggers `django.request` logging and Sentry capture) —
|
|
133
|
+
# returning a 500 envelope here instead swallows that unless we log explicitly first.
|
|
134
|
+
logger.exception("Unhandled exception in view", exc_info=exc)
|
|
135
|
+
message = str(exc) if settings.DEBUG else str(_("Internal server error."))
|
|
136
|
+
return Response(
|
|
137
|
+
{
|
|
138
|
+
"error": {
|
|
139
|
+
"code": "server_error",
|
|
140
|
+
"message": message,
|
|
141
|
+
"details": {},
|
|
142
|
+
"request_id": request_id_var.get(),
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
status=500,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
code = _code_for(exc)
|
|
149
|
+
message, details = _message_and_details(response.data, code=code)
|
|
150
|
+
response.data = {
|
|
151
|
+
"error": {
|
|
152
|
+
"code": code,
|
|
153
|
+
"message": message,
|
|
154
|
+
"details": details,
|
|
155
|
+
"request_id": request_id_var.get(),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return response
|