django-common-kit 0.11.0__tar.gz
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-0.11.0/LICENSE +21 -0
- django_common_kit-0.11.0/PKG-INFO +429 -0
- django_common_kit-0.11.0/README.md +392 -0
- django_common_kit-0.11.0/django_common_kit/__init__.py +37 -0
- django_common_kit-0.11.0/django_common_kit/admin.py +187 -0
- django_common_kit-0.11.0/django_common_kit/api/__init__.py +6 -0
- django_common_kit-0.11.0/django_common_kit/api/errors.py +190 -0
- django_common_kit-0.11.0/django_common_kit/api/exceptions.py +192 -0
- django_common_kit-0.11.0/django_common_kit/api/pagination.py +120 -0
- django_common_kit-0.11.0/django_common_kit/api/permissions.py +117 -0
- django_common_kit-0.11.0/django_common_kit/api/rate_limiters.py +286 -0
- django_common_kit-0.11.0/django_common_kit/api/response.py +392 -0
- django_common_kit-0.11.0/django_common_kit/api/throttling.py +266 -0
- django_common_kit-0.11.0/django_common_kit/api/views.py +243 -0
- django_common_kit-0.11.0/django_common_kit/apps.py +87 -0
- django_common_kit-0.11.0/django_common_kit/conf.py +350 -0
- django_common_kit-0.11.0/django_common_kit/constants/__init__.py +3 -0
- django_common_kit-0.11.0/django_common_kit/constants/error_messages.py +66 -0
- django_common_kit-0.11.0/django_common_kit/crypto/__init__.py +53 -0
- django_common_kit-0.11.0/django_common_kit/crypto/checks.py +67 -0
- django_common_kit-0.11.0/django_common_kit/crypto/fields.py +285 -0
- django_common_kit-0.11.0/django_common_kit/crypto/keys.py +221 -0
- django_common_kit-0.11.0/django_common_kit/crypto/serializers.py +54 -0
- django_common_kit-0.11.0/django_common_kit/files.py +75 -0
- django_common_kit-0.11.0/django_common_kit/history.py +279 -0
- django_common_kit-0.11.0/django_common_kit/management/__init__.py +0 -0
- django_common_kit-0.11.0/django_common_kit/management/commands/__init__.py +0 -0
- django_common_kit-0.11.0/django_common_kit/management/commands/adopt_tables.py +412 -0
- django_common_kit-0.11.0/django_common_kit/management/commands/purge_history.py +47 -0
- django_common_kit-0.11.0/django_common_kit/management/commands/purge_tracking.py +14 -0
- django_common_kit-0.11.0/django_common_kit/management/commands/rotate_encrypted_fields.py +169 -0
- django_common_kit-0.11.0/django_common_kit/management/commands/seed_parameters.py +69 -0
- django_common_kit-0.11.0/django_common_kit/management/commands/unblock_ip.py +29 -0
- django_common_kit-0.11.0/django_common_kit/middleware.py +55 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0001_parameters.py +64 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0002_model_history.py +66 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0003_model_history_correlation_id.py +22 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0004_request_logs.py +63 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0005_blocked_ips.py +55 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0006_ip_tracking.py +80 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0007_contact_us.py +56 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0008_status_transitions.py +60 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0009_common_files.py +62 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0010_short_links.py +56 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0011_request_log_query_string.py +19 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0012_blocked_ip_blocked_at.py +20 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0013_ip_tracking_attribution.py +50 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0014_tenant_id.py +61 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0015_status_transition_field_name.py +20 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0016_tenant_indexes.py +23 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0017_platform_notices.py +58 -0
- django_common_kit-0.11.0/django_common_kit/migrations/0018_platform_notice_dismissals.py +47 -0
- django_common_kit-0.11.0/django_common_kit/migrations/__init__.py +0 -0
- django_common_kit-0.11.0/django_common_kit/models.py +867 -0
- django_common_kit-0.11.0/django_common_kit/notices/__init__.py +5 -0
- django_common_kit-0.11.0/django_common_kit/notices/service.py +158 -0
- django_common_kit-0.11.0/django_common_kit/notices/views.py +47 -0
- django_common_kit-0.11.0/django_common_kit/parameters/__init__.py +15 -0
- django_common_kit-0.11.0/django_common_kit/parameters/cache.py +211 -0
- django_common_kit-0.11.0/django_common_kit/phone.py +456 -0
- django_common_kit-0.11.0/django_common_kit/py.typed +0 -0
- django_common_kit-0.11.0/django_common_kit/request_context.py +170 -0
- django_common_kit-0.11.0/django_common_kit/shortlinks/__init__.py +5 -0
- django_common_kit-0.11.0/django_common_kit/shortlinks/service.py +89 -0
- django_common_kit-0.11.0/django_common_kit/shortlinks/views.py +42 -0
- django_common_kit-0.11.0/django_common_kit/signals.py +165 -0
- django_common_kit-0.11.0/django_common_kit/storage.py +179 -0
- django_common_kit-0.11.0/django_common_kit/tenancy.py +86 -0
- django_common_kit-0.11.0/django_common_kit/tracking/__init__.py +3 -0
- django_common_kit-0.11.0/django_common_kit/tracking/metrics.py +70 -0
- django_common_kit-0.11.0/django_common_kit/tracking/middleware.py +455 -0
- django_common_kit-0.11.0/django_common_kit/tracking/patterns.py +213 -0
- django_common_kit-0.11.0/django_common_kit/tracking/purge.py +58 -0
- django_common_kit-0.11.0/django_common_kit/tracking/redaction.py +163 -0
- django_common_kit-0.11.0/django_common_kit/tracking/writers.py +285 -0
- django_common_kit-0.11.0/django_common_kit/transitions.py +92 -0
- django_common_kit-0.11.0/django_common_kit.egg-info/PKG-INFO +429 -0
- django_common_kit-0.11.0/django_common_kit.egg-info/SOURCES.txt +105 -0
- django_common_kit-0.11.0/django_common_kit.egg-info/dependency_links.txt +1 -0
- django_common_kit-0.11.0/django_common_kit.egg-info/requires.txt +32 -0
- django_common_kit-0.11.0/django_common_kit.egg-info/top_level.txt +1 -0
- django_common_kit-0.11.0/pyproject.toml +60 -0
- django_common_kit-0.11.0/setup.cfg +4 -0
- django_common_kit-0.11.0/tests/test_adopt_tables.py +232 -0
- django_common_kit-0.11.0/tests/test_conf.py +26 -0
- django_common_kit-0.11.0/tests/test_crypto.py +631 -0
- django_common_kit-0.11.0/tests/test_exception_handler.py +48 -0
- django_common_kit-0.11.0/tests/test_files_and_links.py +158 -0
- django_common_kit-0.11.0/tests/test_geo.py +124 -0
- django_common_kit-0.11.0/tests/test_history.py +183 -0
- django_common_kit-0.11.0/tests/test_import_purity.py +55 -0
- django_common_kit-0.11.0/tests/test_middleware.py +104 -0
- django_common_kit-0.11.0/tests/test_migrations.py +154 -0
- django_common_kit-0.11.0/tests/test_notices.py +284 -0
- django_common_kit-0.11.0/tests/test_pagination.py +96 -0
- django_common_kit-0.11.0/tests/test_parameters.py +98 -0
- django_common_kit-0.11.0/tests/test_phone.py +91 -0
- django_common_kit-0.11.0/tests/test_postgres.py +70 -0
- django_common_kit-0.11.0/tests/test_rate_limiters.py +176 -0
- django_common_kit-0.11.0/tests/test_redaction.py +55 -0
- django_common_kit-0.11.0/tests/test_response.py +192 -0
- django_common_kit-0.11.0/tests/test_storage.py +56 -0
- django_common_kit-0.11.0/tests/test_tenancy.py +132 -0
- django_common_kit-0.11.0/tests/test_tracking.py +191 -0
- django_common_kit-0.11.0/tests/test_transitions.py +84 -0
- django_common_kit-0.11.0/tests/test_version.py +28 -0
- django_common_kit-0.11.0/tests/test_views.py +50 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Meheraj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-common-kit
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: Shared Django foundation for REST backends: BaseModel, change history, system parameters, request tracking, file attachments and the REST response contract, and encryption of secrets at rest.
|
|
5
|
+
Author: Meheraj
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/meharaj-007/django-common-kit
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: Django<6.0,>=4.2
|
|
12
|
+
Requires-Dist: djangorestframework<4.0,>=3.14
|
|
13
|
+
Provides-Extra: s3
|
|
14
|
+
Requires-Dist: django-storages<2.0,>=1.13; extra == "s3"
|
|
15
|
+
Requires-Dist: boto3<2.0,>=1.26; extra == "s3"
|
|
16
|
+
Provides-Extra: phone
|
|
17
|
+
Requires-Dist: phonenumbers<10.0,>=8.13; extra == "phone"
|
|
18
|
+
Provides-Extra: images
|
|
19
|
+
Requires-Dist: Pillow<13.0,>=9.0; extra == "images"
|
|
20
|
+
Provides-Extra: tracking
|
|
21
|
+
Requires-Dist: requests<3.0,>=2.28; extra == "tracking"
|
|
22
|
+
Requires-Dist: user-agents<3.0,>=2.2; extra == "tracking"
|
|
23
|
+
Provides-Extra: celery
|
|
24
|
+
Requires-Dist: celery<6.0,>=5.2; extra == "celery"
|
|
25
|
+
Provides-Extra: crypto
|
|
26
|
+
Requires-Dist: cryptography<52,>=42; extra == "crypto"
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Requires-Dist: django-storages<2.0,>=1.13; extra == "all"
|
|
29
|
+
Requires-Dist: boto3<2.0,>=1.26; extra == "all"
|
|
30
|
+
Requires-Dist: phonenumbers<10.0,>=8.13; extra == "all"
|
|
31
|
+
Requires-Dist: Pillow<13.0,>=9.0; extra == "all"
|
|
32
|
+
Requires-Dist: requests<3.0,>=2.28; extra == "all"
|
|
33
|
+
Requires-Dist: user-agents<3.0,>=2.2; extra == "all"
|
|
34
|
+
Requires-Dist: celery<6.0,>=5.2; extra == "all"
|
|
35
|
+
Requires-Dist: cryptography<52,>=42; extra == "all"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# django-common-kit
|
|
39
|
+
|
|
40
|
+
[](https://github.com/meharaj-007/django-common-kit/actions/workflows/tests.yml)
|
|
41
|
+
|
|
42
|
+
The foundation a Django REST backend starts from: a UUID `BaseModel`, a
|
|
43
|
+
change-history trail, typed system parameters with a cache, request and IP
|
|
44
|
+
tracking, file attachments, short links, one REST response envelope with the
|
|
45
|
+
views, pagination, permissions and throttling that produce it, and encryption
|
|
46
|
+
for the secrets a project must store.
|
|
47
|
+
|
|
48
|
+
Every project installs it, configures it and uses it the same way.
|
|
49
|
+
|
|
50
|
+
- [PRD.md](https://github.com/meharaj-007/django-common-kit/blob/main/PRD.md) — the specification, cited by section number from the code.
|
|
51
|
+
- [CHANGELOG.md](https://github.com/meharaj-007/django-common-kit/blob/main/CHANGELOG.md) — what changed.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
From PyPI:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install "django-common-kit==0.11.0"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or pinned to a git tag, which is the same release:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
django-common-kit @ git+https://github.com/meharaj-007/django-common-kit.git@v0.11.0
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Extras, all optional. The package imports and works with none of them; each
|
|
68
|
+
degrades in the way its row says.
|
|
69
|
+
|
|
70
|
+
| Extra | Pulls in | Without it |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `s3` | `django-storages`, `boto3` | `STORAGE["TYPE"] = "s3"` raises naming the extra |
|
|
73
|
+
| `phone` | `phonenumbers` | phone validation is syntax-only — see below |
|
|
74
|
+
| `tracking` | `requests`, `user-agents` | geo and device columns on `ip_tracking` stay blank |
|
|
75
|
+
| `celery` | `celery` | tracking rows are written inline instead of by a worker |
|
|
76
|
+
| `images` | `Pillow` | no image handling on attachments |
|
|
77
|
+
| `crypto` | `cryptography` | `django_common_kit.crypto` raises naming the extra |
|
|
78
|
+
| `all` | everything above | |
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
INSTALLED_APPS = [
|
|
82
|
+
...
|
|
83
|
+
"django_common_kit",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
MIDDLEWARE = [
|
|
87
|
+
"django_common_kit.middleware.CurrentRequestMiddleware", # first
|
|
88
|
+
"django_common_kit.tracking.middleware.IPBlockerMiddleware", # early: a ban costs nothing
|
|
89
|
+
...
|
|
90
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
91
|
+
"django_common_kit.tracking.middleware.RequestLogMiddleware", # after auth
|
|
92
|
+
"django_common_kit.tracking.middleware.IPTrackingMiddleware",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
REST_FRAMEWORK = {
|
|
96
|
+
"EXCEPTION_HANDLER": "django_common_kit.api.exceptions.custom_exception_handler",
|
|
97
|
+
"DEFAULT_PAGINATION_CLASS": "django_common_kit.api.pagination.CustomPageNumberPagination",
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Then `manage.py migrate`. The app label is `common_control`.
|
|
102
|
+
|
|
103
|
+
## What you get
|
|
104
|
+
|
|
105
|
+
| Module | Contents |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `django_common_kit.models` | `BaseModel`, `ParameterModel`, `ModelHistory`, `RequestLog`, `IPTrackingModel`, `BlockedIPModel`, `ContactUsModel`, `StatusTransitionModel`, `CommonFileModel`, `ShortLinkModel`, `PlatformNoticeModel`, `PlatformNoticeDismissalModel` |
|
|
108
|
+
| `django_common_kit.api.response` | `ApiResponse` — the envelope |
|
|
109
|
+
| `django_common_kit.api.exceptions` | `custom_exception_handler`, `reraise_handled`, `BusinessLogicException` and friends |
|
|
110
|
+
| `django_common_kit.api.errors` | `normalize_field_errors`, `extract_first_error_message` |
|
|
111
|
+
| `django_common_kit.api.pagination` | `CustomPageNumberPagination`, `CustomCursorSetPagination` |
|
|
112
|
+
| `django_common_kit.api.views` | `CustomModelViewSet`, `CustomListAPIView`, `CustomWOPListAPIView`, … |
|
|
113
|
+
| `django_common_kit.api.permissions` | `AdminUserPermission`, `IsOwnerOrReadOnly`, `user_type_permission()` |
|
|
114
|
+
| `django_common_kit.api.throttling` | logged DRF throttles, `reset_user_throttles` |
|
|
115
|
+
| `django_common_kit.api.rate_limiters` | `enforce_cooldown`, `api_rate_limit`, `html_rate_limit` |
|
|
116
|
+
| `django_common_kit.history` | `HistoryMixin`, `get_model_history`, `create_history_entry` |
|
|
117
|
+
| `django_common_kit.transitions` | `track_status_transition`, `get_status_transitions` |
|
|
118
|
+
| `django_common_kit.parameters` | `ParameterCache`, `get_parameter` |
|
|
119
|
+
| `django_common_kit.tracking` | redaction, patterns, writers, middleware, purge |
|
|
120
|
+
| `django_common_kit.shortlinks` | `shorten`, `short_link_redirect` |
|
|
121
|
+
| `django_common_kit.notices` | `live_notices`, `dismiss`, `LiveNoticeListView`, `NoticeDismissView` |
|
|
122
|
+
| `django_common_kit.phone` | `Phone` |
|
|
123
|
+
| `django_common_kit.storage` | `MediaStorage`, `scoped_path`, `safe_basename` |
|
|
124
|
+
| `django_common_kit.files` | `validate_upload` — the `FILES` size and type limits |
|
|
125
|
+
| `django_common_kit.request_context` | the current request, actor, client IP, correlation id |
|
|
126
|
+
| `django_common_kit.middleware` | `CurrentRequestMiddleware` |
|
|
127
|
+
| `django_common_kit.admin` | `BaseModelAdmin` |
|
|
128
|
+
| `django_common_kit.constants` | `ErrorMessage` |
|
|
129
|
+
| commands | `seed_parameters`, `purge_history`, `purge_tracking`, `unblock_ip`, `adopt_tables` |
|
|
130
|
+
|
|
131
|
+
## The response envelope
|
|
132
|
+
|
|
133
|
+
Every endpoint answers with one shape:
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"status": "success",
|
|
138
|
+
"status_code": 200,
|
|
139
|
+
"message": "Results retrieved successfully",
|
|
140
|
+
"data": [],
|
|
141
|
+
"meta": {"total_records": 0}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`message`, `data`, `errors` and `meta` are omitted when unset. `data` is omitted
|
|
146
|
+
only when it is `None`, so an empty list still renders as `"data": []`.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from django_common_kit.api.response import ApiResponse
|
|
150
|
+
|
|
151
|
+
return ApiResponse.success(data=serializer.data)
|
|
152
|
+
return ApiResponse.created(data=serializer.data)
|
|
153
|
+
return ApiResponse.validation_error(serializer.errors) # 400, {field: message}
|
|
154
|
+
return ApiResponse.not_found()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Internal exceptions never reach a client.** An error response built inside an
|
|
158
|
+
`except` block has the exception's text cut out unless the exception was written
|
|
159
|
+
for a person (a `ValueError` from a service, a DRF `APIException`, a class from
|
|
160
|
+
your own packages). The traceback is logged; with `DEBUG` on it also comes back
|
|
161
|
+
under `debug`.
|
|
162
|
+
|
|
163
|
+
## Settings
|
|
164
|
+
|
|
165
|
+
One dict. Everything has a default; the full tree and the reasoning per key are
|
|
166
|
+
in [`django_common_kit/conf.py`](https://github.com/meharaj-007/django-common-kit/blob/main/django_common_kit/conf.py).
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
DJANGO_COMMON_KIT = {
|
|
170
|
+
"PAGINATION": {"PAGE_SIZE": 25, "ALLOWED_PAGE_SIZES": [10, 25, 50, 100]},
|
|
171
|
+
"PHONE": {"DEFAULT_REGION": "AU", "SERVICEABLE_REGIONS": ["AU"]},
|
|
172
|
+
"STORAGE": {"TYPE": "s3", "MEDIA_LOCATION": "prod/media"},
|
|
173
|
+
"TRACKING": {"TRUSTED_PROXY_COUNT": 1, "IP_BLOCK_EXEMPT_PATHS": [r"^/api/auth/verify/[^/]*/?$"]},
|
|
174
|
+
"SHORT_LINK": {"BASE_URL": "https://example.com"},
|
|
175
|
+
"PERMISSION_CLASSES": ["myapp.permissions.ResourceActionPermission"],
|
|
176
|
+
"PARAMETERS": {"SEED_CATALOG": "myapp.parameters.CATALOGUE"},
|
|
177
|
+
"RATE_LIMIT": {"CACHE_ALIAS": "throttle"},
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
A multi-tenant project fills the `tenant_id` every package table carries: set
|
|
182
|
+
`TENANT["INSTANCE_ATTRIBUTE"]` to the attribute holding a row's tenant
|
|
183
|
+
(`"organization_id"`) and `TENANT["RESOLVER"]` to a dotted path to
|
|
184
|
+
`callable(request) -> tenant id`. Unset, `tenant_id` stays empty.
|
|
185
|
+
|
|
186
|
+
A project whose clients branch on a machine-readable error code, or quote a
|
|
187
|
+
trace id back, turns on `RESPONSE["ERROR_CODES"]` and sets
|
|
188
|
+
`RESPONSE["ERROR_TRACE_ID_KEY"]` (say, `"trace_id"`). Both are off by default,
|
|
189
|
+
so the envelope gains no key a client did not ask for.
|
|
190
|
+
|
|
191
|
+
Dotted paths (`PERMISSION_CLASSES`, `SEED_CATALOG`, `AUTHORED_EXCEPTION_PACKAGES`)
|
|
192
|
+
resolve at first use, never at import, so your own modules are not imported
|
|
193
|
+
while the app registry is still loading.
|
|
194
|
+
|
|
195
|
+
Three defaults to decide on rather than inherit:
|
|
196
|
+
|
|
197
|
+
- **`TRACKING["TRUSTED_PROXY_COUNT"]` is 0**, which ignores `X-Forwarded-For`
|
|
198
|
+
entirely — the only safe default, since a client can forge any hop the count
|
|
199
|
+
trusts. Behind nginx or a load balancer, set it to the number of proxies you
|
|
200
|
+
run. Left at 0, every client appears as the proxy's address, so every rate
|
|
201
|
+
limit and IP block applies to all of them at once. If the app's port is
|
|
202
|
+
reachable without the proxy, also list the proxies in
|
|
203
|
+
`TRACKING["TRUSTED_PROXY_IPS"]` (addresses or CIDRs), so a direct request
|
|
204
|
+
cannot pick its own address.
|
|
205
|
+
- **`PERMISSION_CLASSES` is empty**, so the `Custom*` views require a signed-in
|
|
206
|
+
user and nothing more. Name your RBAC class here.
|
|
207
|
+
- **`RATE_LIMIT["CACHE_ALIAS"]` is `default`.** If `default` is `DummyCache`
|
|
208
|
+
(common under `DEBUG`), no throttle or rate limit holds; point it at a real
|
|
209
|
+
cache.
|
|
210
|
+
|
|
211
|
+
## Models
|
|
212
|
+
|
|
213
|
+
Inherit `BaseModel` and every row gets a UUID id, `is_active`, `is_deleted`,
|
|
214
|
+
`created_at`/`updated_at`, `created_by`/`updated_by`, and a history trail.
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from django_common_kit.models import BaseModel
|
|
218
|
+
|
|
219
|
+
class Job(BaseModel):
|
|
220
|
+
title = models.CharField(max_length=200)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Every save writes a `model_history` row with field-level before/after and the
|
|
224
|
+
acting user, read from the request in flight — which is why
|
|
225
|
+
`CurrentRequestMiddleware` must be installed. A save outside a request records
|
|
226
|
+
no actor rather than guessing one. Set `_track_history = False` on a model to
|
|
227
|
+
switch it off, and do so on every telemetry table.
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
from django_common_kit.history import get_model_history
|
|
231
|
+
|
|
232
|
+
for row in get_model_history(job):
|
|
233
|
+
print(row.action, row.changed_by, row.field_changes)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Status transitions
|
|
237
|
+
|
|
238
|
+
Written when your service changes a status, not by a signal. Call it *before*
|
|
239
|
+
assigning the new value, so the previous one is read from the instance:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
from django_common_kit.transitions import track_status_transition, get_status_transitions
|
|
243
|
+
|
|
244
|
+
track_status_transition(job, JobStatus.DONE, "employee", transition_reason="Signed off")
|
|
245
|
+
job.status = JobStatus.DONE
|
|
246
|
+
job.save()
|
|
247
|
+
|
|
248
|
+
get_status_transitions(job) # newest first
|
|
249
|
+
get_status_transitions(job, field_name="status") # one field only
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`transition_source` is your own word, at most 20 characters; a longer or empty
|
|
253
|
+
one raises `ValueError`. `field_name` defaults to `"status"`.
|
|
254
|
+
|
|
255
|
+
## File uploads
|
|
256
|
+
|
|
257
|
+
`FILES["MAX_UPLOAD_BYTES"]` (default 10 MB; `0` for no limit) and
|
|
258
|
+
`FILES["ALLOWED_MIME_TYPES"]` (default any; `"image/*"` for a family) are
|
|
259
|
+
checked by one validator. The admin and model forms apply it through
|
|
260
|
+
`CommonFileModel.clean()`; a serializer puts it on its field:
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
from django_common_kit.files import validate_upload
|
|
264
|
+
|
|
265
|
+
file = serializers.FileField(validators=[validate_upload]) # 400 on that field
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Files already stored are never re-checked, so lowering a limit does not lock
|
|
269
|
+
old rows.
|
|
270
|
+
|
|
271
|
+
## Parameters
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from django_common_kit.parameters import ParameterCache
|
|
275
|
+
|
|
276
|
+
ParameterCache.get_int("MAX_JOBS_PER_DAY", default=10)
|
|
277
|
+
ParameterCache.get_bool("MAINTENANCE_MODE")
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Only `is_system=True` rows are cached and served. Parameter *names* are yours:
|
|
281
|
+
point `PARAMETERS["SEED_CATALOG"]` at a list of dicts and run
|
|
282
|
+
`manage.py seed_parameters`.
|
|
283
|
+
|
|
284
|
+
## Request tracking
|
|
285
|
+
|
|
286
|
+
Bodies are redacted **before** they are written and **before** they are
|
|
287
|
+
truncated; a credential is never on disk. Rows go to Celery when it is installed
|
|
288
|
+
and are written inline when it is not.
|
|
289
|
+
|
|
290
|
+
The IP blocker bans on the third probe for `.env`, `.git`, `wp-admin` and the
|
|
291
|
+
rest of the list in `tracking/patterns.py`. Every pattern is anchored to a path
|
|
292
|
+
segment — a bare substring matches the random token in a verification link and
|
|
293
|
+
bans the customer's whole office. List your own token-bearing routes in
|
|
294
|
+
`TRACKING["IP_BLOCK_EXEMPT_PATHS"]`. A ban lifts after seven days, is reversible
|
|
295
|
+
from the admin without a deploy, and `manage.py unblock_ip <ip>` is the escape
|
|
296
|
+
hatch when the banned address is your own.
|
|
297
|
+
|
|
298
|
+
Sweep daily: `manage.py purge_tracking`, `manage.py purge_history`.
|
|
299
|
+
|
|
300
|
+
## Phone numbers
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
from django_common_kit.phone import Phone
|
|
304
|
+
|
|
305
|
+
Phone.normalise("0401773013") # '+61401773013'
|
|
306
|
+
Phone.normalise("ask for Dave") # None
|
|
307
|
+
Phone.normalise_or_original("ask for Dave") # 'ask for Dave' <- use this on save
|
|
308
|
+
Phone.is_serviceable("+1 415 555 0132") # False, with SERVICEABLE_REGIONS = ["AU"]
|
|
309
|
+
Phone.is_same_number("0412 345 678", "+61412345678") # True
|
|
310
|
+
Phone.search_q("phone_number", "0400") # a Q matching either spelling
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
> **Install `django-common-kit[phone]` before you send SMS.** Without
|
|
314
|
+
> `phonenumbers`, validation falls back to checking E.164 *syntax*, which cannot
|
|
315
|
+
> tell that a well-formed string is undialable.
|
|
316
|
+
|
|
317
|
+
## Short links
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
from django_common_kit.shortlinks import shorten
|
|
321
|
+
shorten("https://example.com/quote/abc?sig=…", purpose="quote") # 'https://example.com/s/Ab3xK9q/'
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Mount the redirect: `path("s/<slug:slug>/", short_link_redirect)`. Returns the
|
|
325
|
+
long URL unchanged on any failure, so a message still goes out.
|
|
326
|
+
|
|
327
|
+
## Platform notices
|
|
328
|
+
|
|
329
|
+
A maintenance window, an outage or a change of terms, shown to the people using
|
|
330
|
+
the platform. Posted in the admin ("Platform Notices"); shown by the frontend,
|
|
331
|
+
never sent.
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
from django_common_kit.notices.views import LiveNoticeListView, NoticeDismissView
|
|
335
|
+
|
|
336
|
+
path("notices/", LiveNoticeListView.as_view()), # GET ?surface=web
|
|
337
|
+
path("notices/<uuid:notice_id>/dismiss/", NoticeDismissView.as_view()), # POST
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
A notice is live between `starts_at` and `ends_at` (either may be empty) while
|
|
341
|
+
`is_active`. Three columns narrow who sees it, and each left empty means
|
|
342
|
+
*every*:
|
|
343
|
+
|
|
344
|
+
- `tenant_id` — one tenant, else all of them. Never filled from the request.
|
|
345
|
+
- `audience` — a string in your own vocabulary (`"admins"`). A viewer's
|
|
346
|
+
audiences come from `NOTICES["AUDIENCE_RESOLVER"]`, a dotted path to
|
|
347
|
+
`callable(request) -> iterable of str`. Unset, the viewer has none and sees
|
|
348
|
+
only notices with no audience.
|
|
349
|
+
- `surface` — where it shows (`"web"`, `"ios"`), as the client passes it in
|
|
350
|
+
`?surface=`. A client that names none sees only notices for every surface.
|
|
351
|
+
|
|
352
|
+
A dismissal is stored per user, so it holds on every device; a notice with
|
|
353
|
+
`is_dismissible=False` stays until it ends. The live list is cached for
|
|
354
|
+
`NOTICES["CACHE_TTL_SECONDS"]` (60) and dropped on every save.
|
|
355
|
+
|
|
356
|
+
## Encrypted secrets
|
|
357
|
+
|
|
358
|
+
For a secret the project must read back — a provider auth token, an OAuth
|
|
359
|
+
refresh token, a webhook signing secret. Passwords are hashed by Django's auth,
|
|
360
|
+
never encrypted. Needs the `crypto` extra. PRD §17 has the full contract.
|
|
361
|
+
|
|
362
|
+
```python
|
|
363
|
+
DJANGO_COMMON_KIT = {
|
|
364
|
+
# Newest first: the first key encrypts, every key decrypts.
|
|
365
|
+
"ENCRYPTION": {"KEYS": env.list("ENCRYPTION_KEYS")},
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Generate a key with
|
|
370
|
+
`python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
|
|
371
|
+
and keep it in the environment, never in source. There is no fallback derived
|
|
372
|
+
from `SECRET_KEY`: no key, no encryption, and `manage.py check` says so
|
|
373
|
+
(`common_control.E001`–`E003`, `W001`).
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
from django_common_kit.crypto import EncryptedTextField, encrypt, decrypt, mask
|
|
377
|
+
|
|
378
|
+
class ProviderAccountModel(BaseModel):
|
|
379
|
+
auth_token = EncryptedTextField(blank=True)
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
- The column holds a Fernet token; the attribute is the plaintext, decrypted
|
|
383
|
+
the first time it is read. Loading rows never needs a key, and a row no key
|
|
384
|
+
opens raises `DecryptionError` only where its secret is read.
|
|
385
|
+
- A value read and saved back unchanged keeps its token. A new value is
|
|
386
|
+
encrypted on `save()`, `update()`, `bulk_create` and `bulk_update`.
|
|
387
|
+
- Only `isnull` and `=""` are lookups; any other filter on it raises
|
|
388
|
+
`FieldError`, since a randomised token can never match.
|
|
389
|
+
- History records a change as `"***"` and never snapshots the value.
|
|
390
|
+
- Forms render a password input that never shows the stored value; submitted
|
|
391
|
+
blank, it keeps it. `BaseModelAdmin` shows the mask in `list_display`. Do not
|
|
392
|
+
put the field in `readonly_fields`: Django displays those as plain text.
|
|
393
|
+
- For DRF, add `SecretFieldsMixin` before `ModelSerializer`: the field accepts
|
|
394
|
+
the plaintext and reads back as `{"is_set": true, "masked": "••••1234"}`.
|
|
395
|
+
- `dumpdata` writes the token; `loaddata` would encrypt it a second time, so
|
|
396
|
+
set secrets in fixtures from code instead.
|
|
397
|
+
|
|
398
|
+
**Moving an existing column in.** The tokens are plain Fernet, so a column
|
|
399
|
+
written by any other Fernet helper reads back once its key is in `KEYS`: add
|
|
400
|
+
the key, change the field to `EncryptedTextField` (an `AlterField` with no
|
|
401
|
+
schema change), delete the local helper. A column that held plaintext takes
|
|
402
|
+
`legacy_plaintext=True` until `rotate_encrypted_fields --include-plaintext`
|
|
403
|
+
has encrypted it.
|
|
404
|
+
|
|
405
|
+
**Rotating a key.** Deploy with `KEYS = [new, old]`, run
|
|
406
|
+
`manage.py rotate_encrypted_fields` (every encrypted field of every installed
|
|
407
|
+
model; `--dry-run`, `--app`, `--model`, `--batch-size`), and deploy with
|
|
408
|
+
`KEYS = [new]` once it reports no failures. It writes no history and does not
|
|
409
|
+
touch `updated_at`, includes soft-deleted rows, and can be stopped and run
|
|
410
|
+
again.
|
|
411
|
+
|
|
412
|
+
## Development
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
python3 -m django test tests --settings=tests.settings
|
|
416
|
+
DJANGO_COMMON_KIT_TEST_DB=postgres python3 -m django test tests --settings=tests.settings
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
The Postgres leg is not optional before a tag: `test_postgres` pins the column
|
|
420
|
+
types and runs only there.
|
|
421
|
+
|
|
422
|
+
## Branches and releasing
|
|
423
|
+
|
|
424
|
+
Work on `dev`. `main` moves only when a release is fast-forwarded to it. The tag
|
|
425
|
+
must equal `django_common_kit.__version__`, and a published tag is never moved.
|
|
426
|
+
|
|
427
|
+
Publishing a GitHub Release for a tag uploads that version to PyPI
|
|
428
|
+
(`.github/workflows/publish.yml`, trusted publishing, no token). The workflow
|
|
429
|
+
can also be started by hand with the tag as its ref.
|