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.
- django_common_kit/__init__.py +37 -0
- django_common_kit/admin.py +187 -0
- django_common_kit/api/__init__.py +6 -0
- django_common_kit/api/errors.py +190 -0
- django_common_kit/api/exceptions.py +192 -0
- django_common_kit/api/pagination.py +120 -0
- django_common_kit/api/permissions.py +117 -0
- django_common_kit/api/rate_limiters.py +286 -0
- django_common_kit/api/response.py +392 -0
- django_common_kit/api/throttling.py +266 -0
- django_common_kit/api/views.py +243 -0
- django_common_kit/apps.py +87 -0
- django_common_kit/conf.py +350 -0
- django_common_kit/constants/__init__.py +3 -0
- django_common_kit/constants/error_messages.py +66 -0
- django_common_kit/crypto/__init__.py +53 -0
- django_common_kit/crypto/checks.py +67 -0
- django_common_kit/crypto/fields.py +285 -0
- django_common_kit/crypto/keys.py +221 -0
- django_common_kit/crypto/serializers.py +54 -0
- django_common_kit/files.py +75 -0
- django_common_kit/history.py +279 -0
- django_common_kit/management/__init__.py +0 -0
- django_common_kit/management/commands/__init__.py +0 -0
- django_common_kit/management/commands/adopt_tables.py +412 -0
- django_common_kit/management/commands/purge_history.py +47 -0
- django_common_kit/management/commands/purge_tracking.py +14 -0
- django_common_kit/management/commands/rotate_encrypted_fields.py +169 -0
- django_common_kit/management/commands/seed_parameters.py +69 -0
- django_common_kit/management/commands/unblock_ip.py +29 -0
- django_common_kit/middleware.py +55 -0
- django_common_kit/migrations/0001_parameters.py +64 -0
- django_common_kit/migrations/0002_model_history.py +66 -0
- django_common_kit/migrations/0003_model_history_correlation_id.py +22 -0
- django_common_kit/migrations/0004_request_logs.py +63 -0
- django_common_kit/migrations/0005_blocked_ips.py +55 -0
- django_common_kit/migrations/0006_ip_tracking.py +80 -0
- django_common_kit/migrations/0007_contact_us.py +56 -0
- django_common_kit/migrations/0008_status_transitions.py +60 -0
- django_common_kit/migrations/0009_common_files.py +62 -0
- django_common_kit/migrations/0010_short_links.py +56 -0
- django_common_kit/migrations/0011_request_log_query_string.py +19 -0
- django_common_kit/migrations/0012_blocked_ip_blocked_at.py +20 -0
- django_common_kit/migrations/0013_ip_tracking_attribution.py +50 -0
- django_common_kit/migrations/0014_tenant_id.py +61 -0
- django_common_kit/migrations/0015_status_transition_field_name.py +20 -0
- django_common_kit/migrations/0016_tenant_indexes.py +23 -0
- django_common_kit/migrations/0017_platform_notices.py +58 -0
- django_common_kit/migrations/0018_platform_notice_dismissals.py +47 -0
- django_common_kit/migrations/__init__.py +0 -0
- django_common_kit/models.py +867 -0
- django_common_kit/notices/__init__.py +5 -0
- django_common_kit/notices/service.py +158 -0
- django_common_kit/notices/views.py +47 -0
- django_common_kit/parameters/__init__.py +15 -0
- django_common_kit/parameters/cache.py +211 -0
- django_common_kit/phone.py +456 -0
- django_common_kit/py.typed +0 -0
- django_common_kit/request_context.py +170 -0
- django_common_kit/shortlinks/__init__.py +5 -0
- django_common_kit/shortlinks/service.py +89 -0
- django_common_kit/shortlinks/views.py +42 -0
- django_common_kit/signals.py +165 -0
- django_common_kit/storage.py +179 -0
- django_common_kit/tenancy.py +86 -0
- django_common_kit/tracking/__init__.py +3 -0
- django_common_kit/tracking/metrics.py +70 -0
- django_common_kit/tracking/middleware.py +455 -0
- django_common_kit/tracking/patterns.py +213 -0
- django_common_kit/tracking/purge.py +58 -0
- django_common_kit/tracking/redaction.py +163 -0
- django_common_kit/tracking/writers.py +285 -0
- django_common_kit/transitions.py +92 -0
- django_common_kit-0.11.0.dist-info/METADATA +429 -0
- django_common_kit-0.11.0.dist-info/RECORD +78 -0
- django_common_kit-0.11.0.dist-info/WHEEL +5 -0
- django_common_kit-0.11.0.dist-info/licenses/LICENSE +21 -0
- 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)
|