django-common-kit 0.11.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.
Files changed (78) hide show
  1. django_common_kit/__init__.py +37 -0
  2. django_common_kit/admin.py +187 -0
  3. django_common_kit/api/__init__.py +6 -0
  4. django_common_kit/api/errors.py +190 -0
  5. django_common_kit/api/exceptions.py +192 -0
  6. django_common_kit/api/pagination.py +120 -0
  7. django_common_kit/api/permissions.py +117 -0
  8. django_common_kit/api/rate_limiters.py +286 -0
  9. django_common_kit/api/response.py +392 -0
  10. django_common_kit/api/throttling.py +266 -0
  11. django_common_kit/api/views.py +243 -0
  12. django_common_kit/apps.py +87 -0
  13. django_common_kit/conf.py +350 -0
  14. django_common_kit/constants/__init__.py +3 -0
  15. django_common_kit/constants/error_messages.py +66 -0
  16. django_common_kit/crypto/__init__.py +53 -0
  17. django_common_kit/crypto/checks.py +67 -0
  18. django_common_kit/crypto/fields.py +285 -0
  19. django_common_kit/crypto/keys.py +221 -0
  20. django_common_kit/crypto/serializers.py +54 -0
  21. django_common_kit/files.py +75 -0
  22. django_common_kit/history.py +279 -0
  23. django_common_kit/management/__init__.py +0 -0
  24. django_common_kit/management/commands/__init__.py +0 -0
  25. django_common_kit/management/commands/adopt_tables.py +412 -0
  26. django_common_kit/management/commands/purge_history.py +47 -0
  27. django_common_kit/management/commands/purge_tracking.py +14 -0
  28. django_common_kit/management/commands/rotate_encrypted_fields.py +169 -0
  29. django_common_kit/management/commands/seed_parameters.py +69 -0
  30. django_common_kit/management/commands/unblock_ip.py +29 -0
  31. django_common_kit/middleware.py +55 -0
  32. django_common_kit/migrations/0001_parameters.py +64 -0
  33. django_common_kit/migrations/0002_model_history.py +66 -0
  34. django_common_kit/migrations/0003_model_history_correlation_id.py +22 -0
  35. django_common_kit/migrations/0004_request_logs.py +63 -0
  36. django_common_kit/migrations/0005_blocked_ips.py +55 -0
  37. django_common_kit/migrations/0006_ip_tracking.py +80 -0
  38. django_common_kit/migrations/0007_contact_us.py +56 -0
  39. django_common_kit/migrations/0008_status_transitions.py +60 -0
  40. django_common_kit/migrations/0009_common_files.py +62 -0
  41. django_common_kit/migrations/0010_short_links.py +56 -0
  42. django_common_kit/migrations/0011_request_log_query_string.py +19 -0
  43. django_common_kit/migrations/0012_blocked_ip_blocked_at.py +20 -0
  44. django_common_kit/migrations/0013_ip_tracking_attribution.py +50 -0
  45. django_common_kit/migrations/0014_tenant_id.py +61 -0
  46. django_common_kit/migrations/0015_status_transition_field_name.py +20 -0
  47. django_common_kit/migrations/0016_tenant_indexes.py +23 -0
  48. django_common_kit/migrations/0017_platform_notices.py +58 -0
  49. django_common_kit/migrations/0018_platform_notice_dismissals.py +47 -0
  50. django_common_kit/migrations/__init__.py +0 -0
  51. django_common_kit/models.py +867 -0
  52. django_common_kit/notices/__init__.py +5 -0
  53. django_common_kit/notices/service.py +158 -0
  54. django_common_kit/notices/views.py +47 -0
  55. django_common_kit/parameters/__init__.py +15 -0
  56. django_common_kit/parameters/cache.py +211 -0
  57. django_common_kit/phone.py +456 -0
  58. django_common_kit/py.typed +0 -0
  59. django_common_kit/request_context.py +170 -0
  60. django_common_kit/shortlinks/__init__.py +5 -0
  61. django_common_kit/shortlinks/service.py +89 -0
  62. django_common_kit/shortlinks/views.py +42 -0
  63. django_common_kit/signals.py +165 -0
  64. django_common_kit/storage.py +179 -0
  65. django_common_kit/tenancy.py +86 -0
  66. django_common_kit/tracking/__init__.py +3 -0
  67. django_common_kit/tracking/metrics.py +70 -0
  68. django_common_kit/tracking/middleware.py +455 -0
  69. django_common_kit/tracking/patterns.py +213 -0
  70. django_common_kit/tracking/purge.py +58 -0
  71. django_common_kit/tracking/redaction.py +163 -0
  72. django_common_kit/tracking/writers.py +285 -0
  73. django_common_kit/transitions.py +92 -0
  74. django_common_kit-0.11.0.dist-info/METADATA +429 -0
  75. django_common_kit-0.11.0.dist-info/RECORD +78 -0
  76. django_common_kit-0.11.0.dist-info/WHEEL +5 -0
  77. django_common_kit-0.11.0.dist-info/licenses/LICENSE +21 -0
  78. django_common_kit-0.11.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,37 @@
1
+ """django-common-kit — the shared Django foundation.
2
+
3
+ The foundation a Django REST backend starts from: a UUID ``BaseModel``,
4
+ a change-history trail, a parameter table with a cache in front of it, request
5
+ and IP tracking, a generic file attachment, and one REST response envelope.
6
+
7
+ The dividing line is vocabulary: **if it has no business meaning, this package
8
+ owns it; the moment it names a lead, a job, an order, a crew or a role, it does
9
+ not.** A scope helper that filters by crew membership, a status enum for quotes,
10
+ an email template — all project, however much they currently sit in ``common/``.
11
+
12
+ See PRD.md §2 for the boundary in full.
13
+ """
14
+
15
+ __version__ = "0.11.0"
16
+
17
+ default_app_config = "django_common_kit.apps.CommonKitConfig"
18
+
19
+ #: Attribute name -> module it lives in. Both are imported lazily so that
20
+ #: ``import django_common_kit`` does not pull models in before the app registry is
21
+ #: ready — the package is imported by ``INSTALLED_APPS`` itself.
22
+ _LAZY_EXPORTS = {
23
+ "ApiResponse": "django_common_kit.api.response",
24
+ "BaseModel": "django_common_kit.models",
25
+ "ParameterCache": "django_common_kit.parameters.cache",
26
+ }
27
+
28
+
29
+ def __getattr__(name):
30
+ module_path = _LAZY_EXPORTS.get(name)
31
+ if module_path is None:
32
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
33
+ module = __import__(module_path, fromlist=[name])
34
+ return getattr(module, name)
35
+
36
+
37
+ __all__ = ["ApiResponse", "BaseModel", "ParameterCache", "__version__"]
@@ -0,0 +1,187 @@
1
+ """Admin for the package's own tables (PRD §12).
2
+
3
+ Registered here, unconditionally. A project that wants a different admin for
4
+ either model unregisters and re-registers in its own ``admin.py``; that is the
5
+ Django idiom and it is cheaper than a setting.
6
+ """
7
+
8
+ from django.contrib import admin
9
+
10
+ from django_common_kit.models import ModelHistory, ParameterModel
11
+
12
+
13
+ def _secret_column(field):
14
+ """A changelist column for an encrypted field: the masked secret, and a
15
+ word instead of an error for a row no key opens (§17.6)."""
16
+ from django_common_kit.crypto.keys import DecryptionError, EncryptionNotConfigured, mask
17
+
18
+ def column(obj):
19
+ try:
20
+ value = getattr(obj, field.attname)
21
+ except (DecryptionError, EncryptionNotConfigured):
22
+ return "(unreadable)"
23
+ return mask(value) or "-"
24
+
25
+ column.short_description = field.verbose_name
26
+ column.__name__ = f"{field.name}_masked"
27
+ return column
28
+
29
+
30
+ class BaseModelAdmin(admin.ModelAdmin):
31
+ """Audit columns read-only, soft-deleted rows filterable, actor stamped.
32
+
33
+ The base for a project's admin classes, so ``readonly_fields`` is declared
34
+ once and ``created_by`` is never forgotten. An encrypted field named in
35
+ ``list_display`` shows its mask, never the secret.
36
+ """
37
+
38
+ readonly_fields = ("id", "created_at", "updated_at", "created_by", "updated_by")
39
+ list_filter = ("is_active", "is_deleted")
40
+
41
+ def get_list_display(self, request):
42
+ from django_common_kit.crypto.fields import encrypted_fields
43
+
44
+ secrets = {field.name: field for field in encrypted_fields(self.model)}
45
+ return [
46
+ _secret_column(secrets[name]) if isinstance(name, str) and name in secrets else name
47
+ for name in super().get_list_display(request)
48
+ ]
49
+
50
+ def save_model(self, request, obj, form, change):
51
+ if not change and hasattr(obj, "created_by_id") and obj.created_by_id is None:
52
+ obj.created_by = request.user
53
+ if hasattr(obj, "updated_by_id"):
54
+ obj.updated_by = request.user
55
+ super().save_model(request, obj, form, change)
56
+
57
+
58
+ @admin.register(ParameterModel)
59
+ class ParameterAdmin(BaseModelAdmin):
60
+ list_display = ("key", "parameter_type", "value", "category", "is_system", "is_active")
61
+ list_filter = ("parameter_type", "category", "is_system", "is_active")
62
+ search_fields = ("key", "description", "category")
63
+ ordering = ("category", "key")
64
+ fieldsets = (
65
+ (None, {"fields": ("key", "parameter_type", "category", "description", "is_system")}),
66
+ ("Value", {"fields": ("value_text", "value_integer", "value_float", "value_boolean", "value_json")}),
67
+ ("Status", {"fields": ("is_active", "is_deleted")}),
68
+ ("Audit", {"fields": ("id", "created_at", "created_by", "updated_at", "updated_by")}),
69
+ )
70
+
71
+
72
+ @admin.register(ModelHistory)
73
+ class ModelHistoryAdmin(BaseModelAdmin):
74
+ """Read-only. A trail that can be edited is not a trail."""
75
+
76
+ list_display = ("created_at", "content_type", "object_id", "action", "changed_by", "ip_address")
77
+ list_filter = ("action", "content_type")
78
+ search_fields = ("object_id", "change_reason", "changed_by__email")
79
+ date_hierarchy = "created_at"
80
+ readonly_fields = tuple(
81
+ field.name for field in ModelHistory._meta.get_fields() if hasattr(field, "column")
82
+ )
83
+
84
+ def has_add_permission(self, request):
85
+ return False
86
+
87
+ def has_change_permission(self, request, obj=None):
88
+ return False
89
+
90
+
91
+ from django_common_kit.models import ( # noqa: E402
92
+ BlockedIPModel,
93
+ CommonFileModel,
94
+ ContactUsModel,
95
+ IPTrackingModel,
96
+ PlatformNoticeDismissalModel,
97
+ PlatformNoticeModel,
98
+ RequestLog,
99
+ ShortLinkModel,
100
+ StatusTransitionModel,
101
+ )
102
+
103
+
104
+ class ReadOnlyAdmin(BaseModelAdmin):
105
+ """Telemetry is read, never edited."""
106
+
107
+ def has_add_permission(self, request):
108
+ return False
109
+
110
+ def has_change_permission(self, request, obj=None):
111
+ return False
112
+
113
+
114
+ @admin.register(RequestLog)
115
+ class RequestLogAdmin(ReadOnlyAdmin):
116
+ list_display = ("created_at", "method", "endpoint", "status_code", "user", "ip_address", "response_time")
117
+ list_filter = ("method", "status_code")
118
+ search_fields = ("endpoint", "trace_id", "ip_address", "user__email")
119
+ date_hierarchy = "created_at"
120
+
121
+
122
+ @admin.register(IPTrackingModel)
123
+ class IPTrackingAdmin(ReadOnlyAdmin):
124
+ list_display = ("created_at", "ip_address", "endpoint", "country", "city", "device_type", "browser")
125
+ list_filter = ("country", "device_type", "is_mobile")
126
+ search_fields = ("ip_address", "endpoint", "city", "isp")
127
+ date_hierarchy = "created_at"
128
+
129
+
130
+ @admin.register(BlockedIPModel)
131
+ class BlockedIPAdmin(BaseModelAdmin):
132
+ """Editable: unticking ``is_active`` here is how an operator lifts a ban,
133
+ and the blocker re-reads the table every TTL so it takes effect without a
134
+ deploy."""
135
+
136
+ list_display = ("ip_address", "is_active", "attempts", "reason", "first_attempt", "last_attempt", "blocked_at")
137
+ list_filter = ("is_active",)
138
+ search_fields = ("ip_address", "reason")
139
+ readonly_fields = BaseModelAdmin.readonly_fields + ("first_attempt", "last_attempt")
140
+
141
+
142
+ @admin.register(ContactUsModel)
143
+ class ContactUsAdmin(BaseModelAdmin):
144
+ list_display = ("created_at", "name", "email", "subject", "is_checked")
145
+ list_filter = ("is_checked",)
146
+ search_fields = ("name", "email", "subject", "message")
147
+
148
+
149
+ @admin.register(StatusTransitionModel)
150
+ class StatusTransitionAdmin(ReadOnlyAdmin):
151
+ list_display = ("timestamp", "content_type", "object_id", "previous_status", "new_status", "transition_source", "changed_by")
152
+ list_filter = ("transition_source", "content_type", "new_status")
153
+ search_fields = ("object_id", "transition_reason", "notes")
154
+ date_hierarchy = "timestamp"
155
+
156
+
157
+ @admin.register(CommonFileModel)
158
+ class CommonFileAdmin(BaseModelAdmin):
159
+ list_display = ("created_at", "title", "original_filename", "tag", "mime_type", "file_size", "uploaded_by")
160
+ list_filter = ("tag", "mime_type")
161
+ search_fields = ("title", "original_filename", "description")
162
+
163
+
164
+ @admin.register(ShortLinkModel)
165
+ class ShortLinkAdmin(BaseModelAdmin):
166
+ list_display = ("slug", "target_url", "purpose", "click_count", "last_clicked_at", "expires_at", "is_active")
167
+ list_filter = ("purpose", "is_active")
168
+ search_fields = ("slug", "target_url")
169
+ readonly_fields = BaseModelAdmin.readonly_fields + ("click_count", "last_clicked_at")
170
+
171
+
172
+ @admin.register(PlatformNoticeModel)
173
+ class PlatformNoticeAdmin(BaseModelAdmin):
174
+ """Leave ``tenant_id`` empty for a notice every tenant sees."""
175
+
176
+ list_display = ("title", "severity", "audience", "surface", "starts_at", "ends_at", "priority", "is_dismissible", "is_active")
177
+ list_filter = ("severity", "audience", "surface", "is_dismissible", "is_active", "is_deleted")
178
+ search_fields = ("title", "body")
179
+
180
+
181
+ @admin.register(PlatformNoticeDismissalModel)
182
+ class PlatformNoticeDismissalAdmin(ReadOnlyAdmin):
183
+ """Deleting a row here shows the notice to that user again."""
184
+
185
+ list_display = ("created_at", "notice", "user")
186
+ search_fields = ("notice__title",)
187
+ date_hierarchy = "created_at"
@@ -0,0 +1,6 @@
1
+ """The REST surface (PRD §5).
2
+
3
+ Nothing in here is imported at package import time — ``django_common_kit.api`` is a
4
+ namespace, and pulling ``response`` in from this module would drag DRF's settings
5
+ machinery into ``INSTALLED_APPS`` evaluation. Import the module you want.
6
+ """
@@ -0,0 +1,190 @@
1
+ """Turning a validation failure into something a person can read (PRD §5.3).
2
+
3
+ DRF hands you ``serializer.errors``: nested dicts, lists, integer keys from
4
+ ``ListField`` children, ``__all__`` for model-level constraints. This flattener
5
+ has met all of those shapes.
6
+
7
+ Two functions, two jobs, and they are not the same job:
8
+
9
+ - ``normalize_field_errors`` produces the ``errors`` payload: ``{field: message}``
10
+ so a frontend can attach each message to its input.
11
+ - ``extract_first_error_message`` produces the top-level ``message``: one
12
+ sentence naming the field, because "Validation failed" tells a user nothing.
13
+ """
14
+
15
+ import logging
16
+ from typing import Any, Dict
17
+
18
+ from django.http import Http404
19
+ from rest_framework.exceptions import APIException
20
+ from rest_framework.settings import api_settings
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _FALLBACK = "Validation failed"
25
+
26
+
27
+ def reraise_handled(exc: Exception) -> None:
28
+ """Re-raise exceptions the exception handler already renders correctly.
29
+
30
+ A view's broad ``except Exception`` block otherwise flattens a 404 or a
31
+ throttle into a generic 400. Covers ``Http404`` (from ``get_object()``), DRF
32
+ ``NotFound`` — raised by the paginator for an out-of-range page, and an
33
+ ``APIException``, *not* an ``Http404`` — and every other DRF exception type.
34
+
35
+ Call it as the first statement of the ``except Exception`` block::
36
+
37
+ except Exception as exc:
38
+ reraise_handled(exc)
39
+ logger.error(...)
40
+ return ApiResponse.bad_request(...)
41
+ """
42
+ if isinstance(exc, (Http404, APIException)):
43
+ raise exc
44
+
45
+
46
+ def _humanise(field_name: str) -> str:
47
+ return field_name.replace("_", " ").title()
48
+
49
+
50
+ def _is_authored_prose(message: str) -> bool:
51
+ """True for a message written as sentences rather than a one-line field check.
52
+
53
+ A validator that raises "Quotes cannot be accepted after the expiry date.
54
+ Ask the customer to request a new one."
55
+ should not come back as "Expires At: Quotes cannot be accepted…" — the
56
+ author already wrote a whole message and naming the field on the front of it
57
+ makes it read like a bug.
58
+ """
59
+ body = message.strip()
60
+ return ". " in body or "\n" in body
61
+
62
+
63
+ def extract_first_error_message(error_detail: Any) -> str:
64
+ """The first error, as one sentence naming its field.
65
+
66
+ Used as the envelope's top-level ``message``. Recurses into nested details
67
+ and keeps the field name unless the message already carries it, so a caller
68
+ never gets "Start Time: Start time is required."
69
+ """
70
+ if not error_detail:
71
+ return _FALLBACK
72
+
73
+ if isinstance(error_detail, str):
74
+ return error_detail
75
+
76
+ if isinstance(error_detail, (list, tuple)):
77
+ entry = _first_failure(error_detail)
78
+ if entry is None:
79
+ return _FALLBACK
80
+ if isinstance(entry, (dict, list, tuple)):
81
+ return extract_first_error_message(entry)
82
+ return str(entry)
83
+
84
+ if isinstance(error_detail, dict):
85
+ if not error_detail:
86
+ return _FALLBACK
87
+
88
+ first_field = next(iter(error_detail))
89
+ first_field_errors = error_detail[first_field]
90
+ # ListField / ManyRelated child errors are keyed by *index* (an int),
91
+ # e.g. {"to": {0: ["Enter a valid email address."]}} — coerce so the
92
+ # string handling below cannot crash the handler into a 500.
93
+ field_name = str(first_field)
94
+
95
+ # __all__ is Django's non-field key (unique_together, clean()). There is
96
+ # no field to name, so the message stands alone.
97
+ if field_name == "__all__":
98
+ if isinstance(first_field_errors, (list, tuple)) and first_field_errors:
99
+ return str(first_field_errors[0])
100
+ if isinstance(first_field_errors, str):
101
+ return first_field_errors
102
+
103
+ if isinstance(first_field_errors, dict):
104
+ nested = extract_first_error_message(first_field_errors)
105
+ if field_name.lower() not in nested.lower():
106
+ return f"{_humanise(field_name)}: {nested}"
107
+ return nested
108
+
109
+ if isinstance(first_field_errors, (list, tuple)) and _is_structured(first_field_errors):
110
+ nested = extract_first_error_message(first_field_errors)
111
+ if field_name.lower() not in nested.lower():
112
+ return f"{_humanise(field_name)}: {nested}"
113
+ return nested
114
+
115
+ if isinstance(first_field_errors, (list, tuple)):
116
+ if not first_field_errors:
117
+ return f"Field '{field_name}' has validation errors"
118
+ message = str(first_field_errors[0])
119
+ if "is required" in message:
120
+ return f"{_humanise(field_name)} is required."
121
+ if field_name.replace("_", " ").lower() in message.lower():
122
+ return message
123
+ if _is_authored_prose(message):
124
+ return message
125
+ return f"{_humanise(field_name)}: {message}"
126
+
127
+ message = str(first_field_errors)
128
+ if "is required" in message:
129
+ return f"{_humanise(field_name)} is required."
130
+ return message
131
+
132
+ return str(error_detail)
133
+
134
+
135
+ def normalize_field_errors(error_detail: Any) -> Dict[str, str]:
136
+ """Flatten a validation detail into ``{field: message}``.
137
+
138
+ - a list of messages collapses to its first message
139
+ - nested dicts flatten with dotted keys (``"address.postcode"``)
140
+ - a list of nested details — what a ``many=True`` child or a root
141
+ ``ListSerializer`` raises, one entry per item and ``{}`` for each item
142
+ that passed — flattens with the item's index (``"areas.1.city"``), the
143
+ same path a frontend walks to find the input
144
+ - a bare string or list is keyed under DRF's ``NON_FIELD_ERRORS_KEY``
145
+
146
+ Read through ``api_settings`` rather than captured at import: a project that
147
+ sets ``NON_FIELD_ERRORS_KEY`` in ``REST_FRAMEWORK`` gets its key, and a test
148
+ that overrides it is not served a stale one.
149
+ """
150
+ result: Dict[str, str] = {}
151
+ _flatten(error_detail, "", result)
152
+ return result
153
+
154
+
155
+ def _is_structured(value) -> bool:
156
+ """True for a list that holds per-item details rather than messages."""
157
+ return any(isinstance(entry, (dict, list, tuple)) for entry in value)
158
+
159
+
160
+ def _first_failure(entries):
161
+ """The first entry that carries an error; ``{}`` marks an item that passed."""
162
+ for entry in entries:
163
+ if entry or entry == 0:
164
+ return entry
165
+ return None
166
+
167
+
168
+ def _flatten(value: Any, path: str, result: Dict[str, str]) -> None:
169
+ if isinstance(value, dict):
170
+ for key, inner in value.items():
171
+ _flatten(inner, f"{path}.{key}" if path else str(key), result)
172
+ return
173
+
174
+ key = path or api_settings.NON_FIELD_ERRORS_KEY
175
+
176
+ if isinstance(value, (list, tuple)):
177
+ if _is_structured(value):
178
+ for index, entry in enumerate(value):
179
+ if isinstance(entry, (dict, list, tuple)):
180
+ _flatten(entry, f"{path}.{index}" if path else str(index), result)
181
+ elif entry:
182
+ result.setdefault(key, str(entry))
183
+ elif value:
184
+ result[key] = str(value[0])
185
+ elif path:
186
+ result[key] = "Invalid value."
187
+ return
188
+
189
+ if value or path:
190
+ result[key] = str(value)
@@ -0,0 +1,192 @@
1
+ """The DRF exception handler and the package's own exception types (PRD §5.3).
2
+
3
+ Every error leaving a view renders into the envelope, including the ones that
4
+ are easy to forget: ``Http404``, ``PermissionDenied``,
5
+ ``ValidationError`` with its field errors preserved, ``Throttled`` with its wait,
6
+ and ``IntegrityError`` with the offending column named.
7
+
8
+ Install it::
9
+
10
+ REST_FRAMEWORK = {
11
+ "EXCEPTION_HANDLER": "django_common_kit.api.exceptions.custom_exception_handler",
12
+ }
13
+ """
14
+
15
+ import logging
16
+ import re
17
+ from typing import Any, Dict
18
+
19
+ from django.core.exceptions import ValidationError as DjangoValidationError
20
+ from django.db import IntegrityError
21
+ from rest_framework import serializers
22
+ from rest_framework.exceptions import PermissionDenied, Throttled
23
+ from rest_framework.exceptions import ValidationError as DRFValidationError
24
+ from rest_framework.views import exception_handler as drf_exception_handler
25
+
26
+ from django_common_kit.api.errors import ( # noqa: F401 (re-exported)
27
+ extract_first_error_message,
28
+ normalize_field_errors,
29
+ reraise_handled,
30
+ )
31
+ from django_common_kit.api.response import ApiResponse
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ # -- the package's own exception types --------------------------------------
37
+ # Deliberately plain ``Exception`` subclasses, not ``APIException``. They are
38
+ # raised by service-layer code that has no business importing DRF, and the
39
+ # handler below is what gives them a status code.
40
+
41
+ class BusinessLogicException(Exception):
42
+ """A rule the domain enforces was broken. Rendered as 400."""
43
+
44
+
45
+ class ValidationException(Exception):
46
+ """A validation failure raised outside a serializer. Rendered as 400."""
47
+
48
+
49
+ class ResourceNotFoundException(Exception):
50
+ """Rendered as 404. Distinct from ``Http404`` so a service can raise it
51
+ without importing ``django.http``."""
52
+
53
+
54
+ class PermissionDeniedException(Exception):
55
+ """Rendered as 403."""
56
+
57
+
58
+ _LOCAL_EXCEPTION_STATUS = {
59
+ BusinessLogicException: 400,
60
+ ValidationException: 400,
61
+ ResourceNotFoundException: 404,
62
+ PermissionDeniedException: 403,
63
+ }
64
+
65
+ #: Headers DRF derives from the exception itself, which the envelope must carry
66
+ #: through: ``Retry-After`` says how long a 429'd client should back off, and
67
+ #: ``WWW-Authenticate`` is the 401 challenge. Every other header on DRF's
68
+ #: response is renderer-owned (Content-Type and friends) and is regenerated when
69
+ #: this handler builds its own Response, so only these two are copied.
70
+ EXCEPTION_HEADERS = ("Retry-After", "WWW-Authenticate")
71
+
72
+
73
+ def passthrough_exception_headers(response: Any) -> Dict[str, str]:
74
+ """Pull the exception-derived headers off DRF's response so that rebuilding
75
+ the body as an envelope does not drop them."""
76
+ headers = getattr(response, "headers", None) or {}
77
+ return {name: headers[name] for name in EXCEPTION_HEADERS if name in headers}
78
+
79
+
80
+ def log_throttled(exc: Throttled, context: Dict[str, Any]) -> None:
81
+ """Record the request a throttle just rejected, so 429s are not invisible.
82
+
83
+ ``Throttled`` carries only ``wait`` — not the scope, the rate, or the key it
84
+ was counted against — so this pairs with ``LoggedThrottleFailureMixin`` in
85
+ ``django_common_kit.api.throttling``, which logs that half from inside the
86
+ throttle. Together they answer both halves of "we keep getting rate limited":
87
+ which budget ran out, and which endpoint and caller drained it.
88
+
89
+ Best-effort and never raises: a failure here must not turn a 429 into a 500.
90
+ """
91
+ try:
92
+ request = context.get("request")
93
+ view = context.get("view")
94
+ user = getattr(request, "user", None)
95
+ logger.warning(
96
+ "[throttling] 429 %s %s view=%s user=%s retry_after=%ss",
97
+ getattr(request, "method", "?"),
98
+ getattr(request, "path", "?"),
99
+ type(view).__name__ if view is not None else "?",
100
+ getattr(user, "pk", None) if getattr(user, "is_authenticated", False) else "anonymous",
101
+ int(exc.wait or 0),
102
+ )
103
+ except Exception as exc_logging: # noqa: BLE001 - logging must never break the response
104
+ logger.warning("[throttling] 429 (could not describe request): %s", exc_logging)
105
+
106
+
107
+ def handle_validation_error(exc, context) -> Any:
108
+ """Render any flavour of validation error as one consistent response:
109
+ a readable top-level ``message`` plus per-field ``{field: message}``."""
110
+ logger.warning("Validation failed: %s", type(exc).__name__, exc_info=True)
111
+
112
+ # DRFValidationError and serializers.ValidationError are the same class,
113
+ # both exposing ``.detail``. Django's has ``message_dict`` only when the
114
+ # error was raised against fields.
115
+ if isinstance(exc, DjangoValidationError):
116
+ detail = exc.message_dict if hasattr(exc, "message_dict") else exc.messages
117
+ else:
118
+ detail = getattr(exc, "detail", None)
119
+
120
+ return ApiResponse.validation_error(errors=detail)
121
+
122
+
123
+ def custom_exception_handler(exc: Exception, context: Dict[str, Any]):
124
+ """Centralised exception handler for every API error."""
125
+ # Validation errors are handled *before* DRF's handler. DRF renders them as
126
+ # a bare detail dict, which loses the readable top-level message.
127
+ if isinstance(exc, (DRFValidationError, DjangoValidationError, serializers.ValidationError)):
128
+ return handle_validation_error(exc, context)
129
+
130
+ for exc_type, status_code in _LOCAL_EXCEPTION_STATUS.items():
131
+ if isinstance(exc, exc_type):
132
+ logger.info("%s: %s", type(exc).__name__, exc)
133
+ return ApiResponse.error(message=str(exc) or None, status_code=status_code)
134
+
135
+ if isinstance(exc, Throttled):
136
+ log_throttled(exc, context)
137
+
138
+ response = drf_exception_handler(exc, context)
139
+
140
+ if response is not None:
141
+ if hasattr(exc, "detail"):
142
+ detail = exc.detail
143
+ # PermissionDenied with a dict detail (code + message): prefer the
144
+ # message for display but return the whole thing, because a frontend
145
+ # that branches on the code needs the code.
146
+ if isinstance(exc, PermissionDenied) and isinstance(detail, dict) and "message" in detail:
147
+ error_message = detail["message"]
148
+ errors = detail
149
+ else:
150
+ error_message = extract_first_error_message(detail)
151
+ errors = None
152
+ else:
153
+ error_message = str(exc)
154
+ errors = None
155
+
156
+ # ``Retry-After`` only reaches a browser client if CORS exposes it, so
157
+ # the wait is mirrored into ``meta.retry_after`` — clients read the
158
+ # header first and fall back to this.
159
+ meta = None
160
+ if isinstance(exc, Throttled) and exc.wait is not None:
161
+ meta = {"retry_after": int(exc.wait)}
162
+
163
+ return ApiResponse.error(
164
+ message=error_message,
165
+ status_code=response.status_code,
166
+ errors=errors,
167
+ meta=meta,
168
+ headers=passthrough_exception_headers(response),
169
+ )
170
+
171
+ if isinstance(exc, IntegrityError):
172
+ logger.error("Database integrity error", exc_info=True)
173
+ # Postgres spells a unique violation as
174
+ # ``Key (email)=(a@b.com) already exists``. Naming the column turns an
175
+ # opaque 400 into something a form can highlight. The value itself is
176
+ # deliberately not echoed back.
177
+ match = re.search(r"Key \((.*?)\)=\((.*?)\)", str(exc))
178
+ if match:
179
+ field, _value = match.groups()
180
+ return ApiResponse.bad_request(
181
+ message=f"A record with this {field} already exists."
182
+ )
183
+ return ApiResponse.bad_request(message="A data integrity error occurred.")
184
+
185
+ logger.critical("Unhandled server error: %s", type(exc).__name__, exc_info=True)
186
+ return ApiResponse.server_error()
187
+
188
+
189
+ def format_serializer_errors(serializer_errors: Dict[str, Any]) -> str:
190
+ """The first error message from ``serializer.errors``, for a view that
191
+ validates by hand rather than with ``raise_exception=True``."""
192
+ return extract_first_error_message(serializer_errors)