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.
Files changed (107) hide show
  1. django_common_kit-0.11.0/LICENSE +21 -0
  2. django_common_kit-0.11.0/PKG-INFO +429 -0
  3. django_common_kit-0.11.0/README.md +392 -0
  4. django_common_kit-0.11.0/django_common_kit/__init__.py +37 -0
  5. django_common_kit-0.11.0/django_common_kit/admin.py +187 -0
  6. django_common_kit-0.11.0/django_common_kit/api/__init__.py +6 -0
  7. django_common_kit-0.11.0/django_common_kit/api/errors.py +190 -0
  8. django_common_kit-0.11.0/django_common_kit/api/exceptions.py +192 -0
  9. django_common_kit-0.11.0/django_common_kit/api/pagination.py +120 -0
  10. django_common_kit-0.11.0/django_common_kit/api/permissions.py +117 -0
  11. django_common_kit-0.11.0/django_common_kit/api/rate_limiters.py +286 -0
  12. django_common_kit-0.11.0/django_common_kit/api/response.py +392 -0
  13. django_common_kit-0.11.0/django_common_kit/api/throttling.py +266 -0
  14. django_common_kit-0.11.0/django_common_kit/api/views.py +243 -0
  15. django_common_kit-0.11.0/django_common_kit/apps.py +87 -0
  16. django_common_kit-0.11.0/django_common_kit/conf.py +350 -0
  17. django_common_kit-0.11.0/django_common_kit/constants/__init__.py +3 -0
  18. django_common_kit-0.11.0/django_common_kit/constants/error_messages.py +66 -0
  19. django_common_kit-0.11.0/django_common_kit/crypto/__init__.py +53 -0
  20. django_common_kit-0.11.0/django_common_kit/crypto/checks.py +67 -0
  21. django_common_kit-0.11.0/django_common_kit/crypto/fields.py +285 -0
  22. django_common_kit-0.11.0/django_common_kit/crypto/keys.py +221 -0
  23. django_common_kit-0.11.0/django_common_kit/crypto/serializers.py +54 -0
  24. django_common_kit-0.11.0/django_common_kit/files.py +75 -0
  25. django_common_kit-0.11.0/django_common_kit/history.py +279 -0
  26. django_common_kit-0.11.0/django_common_kit/management/__init__.py +0 -0
  27. django_common_kit-0.11.0/django_common_kit/management/commands/__init__.py +0 -0
  28. django_common_kit-0.11.0/django_common_kit/management/commands/adopt_tables.py +412 -0
  29. django_common_kit-0.11.0/django_common_kit/management/commands/purge_history.py +47 -0
  30. django_common_kit-0.11.0/django_common_kit/management/commands/purge_tracking.py +14 -0
  31. django_common_kit-0.11.0/django_common_kit/management/commands/rotate_encrypted_fields.py +169 -0
  32. django_common_kit-0.11.0/django_common_kit/management/commands/seed_parameters.py +69 -0
  33. django_common_kit-0.11.0/django_common_kit/management/commands/unblock_ip.py +29 -0
  34. django_common_kit-0.11.0/django_common_kit/middleware.py +55 -0
  35. django_common_kit-0.11.0/django_common_kit/migrations/0001_parameters.py +64 -0
  36. django_common_kit-0.11.0/django_common_kit/migrations/0002_model_history.py +66 -0
  37. django_common_kit-0.11.0/django_common_kit/migrations/0003_model_history_correlation_id.py +22 -0
  38. django_common_kit-0.11.0/django_common_kit/migrations/0004_request_logs.py +63 -0
  39. django_common_kit-0.11.0/django_common_kit/migrations/0005_blocked_ips.py +55 -0
  40. django_common_kit-0.11.0/django_common_kit/migrations/0006_ip_tracking.py +80 -0
  41. django_common_kit-0.11.0/django_common_kit/migrations/0007_contact_us.py +56 -0
  42. django_common_kit-0.11.0/django_common_kit/migrations/0008_status_transitions.py +60 -0
  43. django_common_kit-0.11.0/django_common_kit/migrations/0009_common_files.py +62 -0
  44. django_common_kit-0.11.0/django_common_kit/migrations/0010_short_links.py +56 -0
  45. django_common_kit-0.11.0/django_common_kit/migrations/0011_request_log_query_string.py +19 -0
  46. django_common_kit-0.11.0/django_common_kit/migrations/0012_blocked_ip_blocked_at.py +20 -0
  47. django_common_kit-0.11.0/django_common_kit/migrations/0013_ip_tracking_attribution.py +50 -0
  48. django_common_kit-0.11.0/django_common_kit/migrations/0014_tenant_id.py +61 -0
  49. django_common_kit-0.11.0/django_common_kit/migrations/0015_status_transition_field_name.py +20 -0
  50. django_common_kit-0.11.0/django_common_kit/migrations/0016_tenant_indexes.py +23 -0
  51. django_common_kit-0.11.0/django_common_kit/migrations/0017_platform_notices.py +58 -0
  52. django_common_kit-0.11.0/django_common_kit/migrations/0018_platform_notice_dismissals.py +47 -0
  53. django_common_kit-0.11.0/django_common_kit/migrations/__init__.py +0 -0
  54. django_common_kit-0.11.0/django_common_kit/models.py +867 -0
  55. django_common_kit-0.11.0/django_common_kit/notices/__init__.py +5 -0
  56. django_common_kit-0.11.0/django_common_kit/notices/service.py +158 -0
  57. django_common_kit-0.11.0/django_common_kit/notices/views.py +47 -0
  58. django_common_kit-0.11.0/django_common_kit/parameters/__init__.py +15 -0
  59. django_common_kit-0.11.0/django_common_kit/parameters/cache.py +211 -0
  60. django_common_kit-0.11.0/django_common_kit/phone.py +456 -0
  61. django_common_kit-0.11.0/django_common_kit/py.typed +0 -0
  62. django_common_kit-0.11.0/django_common_kit/request_context.py +170 -0
  63. django_common_kit-0.11.0/django_common_kit/shortlinks/__init__.py +5 -0
  64. django_common_kit-0.11.0/django_common_kit/shortlinks/service.py +89 -0
  65. django_common_kit-0.11.0/django_common_kit/shortlinks/views.py +42 -0
  66. django_common_kit-0.11.0/django_common_kit/signals.py +165 -0
  67. django_common_kit-0.11.0/django_common_kit/storage.py +179 -0
  68. django_common_kit-0.11.0/django_common_kit/tenancy.py +86 -0
  69. django_common_kit-0.11.0/django_common_kit/tracking/__init__.py +3 -0
  70. django_common_kit-0.11.0/django_common_kit/tracking/metrics.py +70 -0
  71. django_common_kit-0.11.0/django_common_kit/tracking/middleware.py +455 -0
  72. django_common_kit-0.11.0/django_common_kit/tracking/patterns.py +213 -0
  73. django_common_kit-0.11.0/django_common_kit/tracking/purge.py +58 -0
  74. django_common_kit-0.11.0/django_common_kit/tracking/redaction.py +163 -0
  75. django_common_kit-0.11.0/django_common_kit/tracking/writers.py +285 -0
  76. django_common_kit-0.11.0/django_common_kit/transitions.py +92 -0
  77. django_common_kit-0.11.0/django_common_kit.egg-info/PKG-INFO +429 -0
  78. django_common_kit-0.11.0/django_common_kit.egg-info/SOURCES.txt +105 -0
  79. django_common_kit-0.11.0/django_common_kit.egg-info/dependency_links.txt +1 -0
  80. django_common_kit-0.11.0/django_common_kit.egg-info/requires.txt +32 -0
  81. django_common_kit-0.11.0/django_common_kit.egg-info/top_level.txt +1 -0
  82. django_common_kit-0.11.0/pyproject.toml +60 -0
  83. django_common_kit-0.11.0/setup.cfg +4 -0
  84. django_common_kit-0.11.0/tests/test_adopt_tables.py +232 -0
  85. django_common_kit-0.11.0/tests/test_conf.py +26 -0
  86. django_common_kit-0.11.0/tests/test_crypto.py +631 -0
  87. django_common_kit-0.11.0/tests/test_exception_handler.py +48 -0
  88. django_common_kit-0.11.0/tests/test_files_and_links.py +158 -0
  89. django_common_kit-0.11.0/tests/test_geo.py +124 -0
  90. django_common_kit-0.11.0/tests/test_history.py +183 -0
  91. django_common_kit-0.11.0/tests/test_import_purity.py +55 -0
  92. django_common_kit-0.11.0/tests/test_middleware.py +104 -0
  93. django_common_kit-0.11.0/tests/test_migrations.py +154 -0
  94. django_common_kit-0.11.0/tests/test_notices.py +284 -0
  95. django_common_kit-0.11.0/tests/test_pagination.py +96 -0
  96. django_common_kit-0.11.0/tests/test_parameters.py +98 -0
  97. django_common_kit-0.11.0/tests/test_phone.py +91 -0
  98. django_common_kit-0.11.0/tests/test_postgres.py +70 -0
  99. django_common_kit-0.11.0/tests/test_rate_limiters.py +176 -0
  100. django_common_kit-0.11.0/tests/test_redaction.py +55 -0
  101. django_common_kit-0.11.0/tests/test_response.py +192 -0
  102. django_common_kit-0.11.0/tests/test_storage.py +56 -0
  103. django_common_kit-0.11.0/tests/test_tenancy.py +132 -0
  104. django_common_kit-0.11.0/tests/test_tracking.py +191 -0
  105. django_common_kit-0.11.0/tests/test_transitions.py +84 -0
  106. django_common_kit-0.11.0/tests/test_version.py +28 -0
  107. 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
+ [![tests](https://github.com/meharaj-007/django-common-kit/actions/workflows/tests.yml/badge.svg?branch=main)](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.