django-maker-checker 0.4.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 (47) hide show
  1. django_maker_checker-0.4.0/LICENSE +21 -0
  2. django_maker_checker-0.4.0/PKG-INFO +521 -0
  3. django_maker_checker-0.4.0/README.md +468 -0
  4. django_maker_checker-0.4.0/django_maker_checker/__init__.py +3 -0
  5. django_maker_checker-0.4.0/django_maker_checker/actions.py +171 -0
  6. django_maker_checker-0.4.0/django_maker_checker/adapter.py +115 -0
  7. django_maker_checker-0.4.0/django_maker_checker/admin.py +86 -0
  8. django_maker_checker-0.4.0/django_maker_checker/apps.py +14 -0
  9. django_maker_checker-0.4.0/django_maker_checker/audit.py +17 -0
  10. django_maker_checker-0.4.0/django_maker_checker/conf.py +54 -0
  11. django_maker_checker-0.4.0/django_maker_checker/constants.py +126 -0
  12. django_maker_checker-0.4.0/django_maker_checker/exceptions.py +21 -0
  13. django_maker_checker-0.4.0/django_maker_checker/management/__init__.py +0 -0
  14. django_maker_checker-0.4.0/django_maker_checker/management/commands/__init__.py +0 -0
  15. django_maker_checker-0.4.0/django_maker_checker/management/commands/process_approval_slas.py +20 -0
  16. django_maker_checker-0.4.0/django_maker_checker/migrations/0001_initial.py +193 -0
  17. django_maker_checker-0.4.0/django_maker_checker/migrations/0002_policy_revisions.py +44 -0
  18. django_maker_checker-0.4.0/django_maker_checker/migrations/__init__.py +0 -0
  19. django_maker_checker-0.4.0/django_maker_checker/model_change.py +127 -0
  20. django_maker_checker-0.4.0/django_maker_checker/models.py +424 -0
  21. django_maker_checker-0.4.0/django_maker_checker/notify.py +179 -0
  22. django_maker_checker-0.4.0/django_maker_checker/permissions.py +193 -0
  23. django_maker_checker-0.4.0/django_maker_checker/policies.py +333 -0
  24. django_maker_checker-0.4.0/django_maker_checker/py.typed +0 -0
  25. django_maker_checker-0.4.0/django_maker_checker/queries.py +371 -0
  26. django_maker_checker-0.4.0/django_maker_checker/registry.py +167 -0
  27. django_maker_checker-0.4.0/django_maker_checker/services.py +681 -0
  28. django_maker_checker-0.4.0/django_maker_checker/tasks.py +22 -0
  29. django_maker_checker-0.4.0/django_maker_checker.egg-info/PKG-INFO +521 -0
  30. django_maker_checker-0.4.0/django_maker_checker.egg-info/SOURCES.txt +45 -0
  31. django_maker_checker-0.4.0/django_maker_checker.egg-info/dependency_links.txt +1 -0
  32. django_maker_checker-0.4.0/django_maker_checker.egg-info/requires.txt +22 -0
  33. django_maker_checker-0.4.0/django_maker_checker.egg-info/top_level.txt +1 -0
  34. django_maker_checker-0.4.0/pyproject.toml +124 -0
  35. django_maker_checker-0.4.0/setup.cfg +4 -0
  36. django_maker_checker-0.4.0/tests/test_cancelling_and_the_timeline.py +82 -0
  37. django_maker_checker-0.4.0/tests/test_configuring_rules.py +132 -0
  38. django_maker_checker-0.4.0/tests/test_deciding.py +170 -0
  39. django_maker_checker-0.4.0/tests/test_defining_actions.py +164 -0
  40. django_maker_checker-0.4.0/tests/test_delegating.py +96 -0
  41. django_maker_checker-0.4.0/tests/test_host_contract.py +104 -0
  42. django_maker_checker-0.4.0/tests/test_notifications.py +93 -0
  43. django_maker_checker-0.4.0/tests/test_queues_and_display.py +134 -0
  44. django_maker_checker-0.4.0/tests/test_rule_history.py +88 -0
  45. django_maker_checker-0.4.0/tests/test_sla_and_escalation.py +59 -0
  46. django_maker_checker-0.4.0/tests/test_submitting.py +115 -0
  47. django_maker_checker-0.4.0/tests/test_switching_approvals_off.py +74 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josephat Macharia
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,521 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-maker-checker
3
+ Version: 0.4.0
4
+ Summary: Maker-checker (four-eyes) approval of sensitive changes for Django: tenant-configured rules, segregation of duties, SLA escalation and a full audit trail.
5
+ Author: Josephat Macharia
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/joemash/django-maker-checker
8
+ Project-URL: Documentation, https://django-maker-checker.readthedocs.io
9
+ Project-URL: Changelog, https://github.com/joemash/django-maker-checker/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/joemash/django-maker-checker/issues
11
+ Keywords: django,maker-checker,four-eyes,approvals,segregation-of-duties,audit,workflow
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 5.2
16
+ Classifier: Framework :: Django :: 6.0
17
+ Classifier: Framework :: Django :: 6.1
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Intended Audience :: Financial and Insurance Industry
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Office/Business :: Financial
27
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: >=3.11
30
+ Description-Content-Type: text/markdown
31
+ License-File: LICENSE
32
+ Requires-Dist: Django<6.2,>=5.2
33
+ Requires-Dist: django-fsm-2<5,>=4.2
34
+ Provides-Extra: celery
35
+ Requires-Dist: celery<6,>=5.3; extra == "celery"
36
+ Provides-Extra: dev
37
+ Requires-Dist: pytest>=8.0; extra == "dev"
38
+ Requires-Dist: pytest-django>=4.8; extra == "dev"
39
+ Requires-Dist: pytest-cov; extra == "dev"
40
+ Requires-Dist: coverage; extra == "dev"
41
+ Requires-Dist: psycopg[binary]>=3.1; extra == "dev"
42
+ Requires-Dist: celery<6,>=5.3; extra == "dev"
43
+ Requires-Dist: ruff>=0.5; extra == "dev"
44
+ Requires-Dist: tox; extra == "dev"
45
+ Requires-Dist: bandit[toml]; extra == "dev"
46
+ Requires-Dist: radon; extra == "dev"
47
+ Requires-Dist: vulture; extra == "dev"
48
+ Requires-Dist: pip-audit; extra == "dev"
49
+ Requires-Dist: pre-commit>=3.0.0; extra == "dev"
50
+ Requires-Dist: build; extra == "dev"
51
+ Requires-Dist: twine; extra == "dev"
52
+ Dynamic: license-file
53
+
54
+ # django-maker-checker
55
+
56
+ [![CI](https://github.com/joemash/django-maker-checker/actions/workflows/ci.yml/badge.svg)](https://github.com/joemash/django-maker-checker/actions/workflows/ci.yml)
57
+ [![PyPI](https://img.shields.io/pypi/v/django-maker-checker.svg)](https://pypi.org/project/django-maker-checker/)
58
+ [![Python](https://img.shields.io/pypi/pyversions/django-maker-checker.svg)](https://pypi.org/project/django-maker-checker/)
59
+ [![Docs](https://readthedocs.org/projects/django-maker-checker/badge/?version=latest)](https://django-maker-checker.readthedocs.io)
60
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
61
+
62
+ **Documentation: https://django-maker-checker.readthedocs.io**
63
+
64
+ **Maker-checker (four-eyes) approval of sensitive changes for Django.**
65
+
66
+ When a user (the *maker*) performs a guarded action, such as creating a user, changing a credit limit or approving a
67
+ payment run, the change is **not applied**. It becomes an approval request that someone else (a *checker*) must
68
+ approve, under rules each tenant configures. Only then is the change applied, re-checked against the current state of
69
+ the data.
70
+
71
+ It is built for the controls that regulated businesses (banks, fintechs, back offices) are audited on:
72
+
73
+ - **The change is held, not just flagged.** The action is captured and applied only on approval. If the data changed
74
+ meanwhile, the request fails instead of overwriting someone else's edit.
75
+ - **Segregation of duties is enforced.** A maker can never approve their own request, not as a superuser and not
76
+ through a delegation. One person can't approve two steps. A request can't be raised if nobody else could approve it.
77
+ - **Rules are data, not code.** Each tenant decides which actions need approval, by whom (users or roles), in what
78
+ workflow, and how fast, without a deploy. Every rule change is versioned with who, when and why.
79
+ - **Nothing gets stuck.** Each step has an SLA with a reminder and escalation to a backup approver.
80
+ - **Everything is on the record.** Every request has a timeline, every decision is stored, and every event is passed to
81
+ your audit log.
82
+ - **Concurrency-safe.** Decisions lock the row, and only one open request may exist per item.
83
+
84
+ It is **headless**: it ships models, rules, services and read queries, and no UI. You build the screens your product
85
+ needs on its Python API.
86
+
87
+ ### How it compares
88
+
89
+ | | django-maker-checker | Workflow engines (e.g. django-approval-workflow) | Moderation (e.g. django-approval) |
90
+ |---|---|---|---|
91
+ | What is approved | A pending change, applied on approval | An existing object moving through steps | An edit to a model, kept in a sandbox |
92
+ | Who decides what needs approval | Tenant-configured rules (data) | Code, per flow | Code, per model |
93
+ | Maker can't approve own change | Enforced | — | Privileged users bypass by default |
94
+ | SLA and escalation | Built in | Tracked; you run the timeout loop | — |
95
+ | Multi-tenant | Built in | — | — |
96
+
97
+ If you need rich routing (quorums, org-chart hierarchies, parallel tracks), a workflow engine is the better fit. If you
98
+ need "nobody changes this alone, and we can prove it", this is.
99
+
100
+ ---
101
+
102
+ ## Requirements
103
+
104
+ - Python 3.11+ and Django 5.2, 6.0 or 6.1
105
+ - [django-fsm-2](https://github.com/django-commons/django-fsm-2) (installed automatically)
106
+ - **PostgreSQL** is recommended for production. SQLite works for development and tests. On **MySQL / MariaDB** the
107
+ "one open request per item" guarantee is not enforced, because those databases ignore Django's conditional unique
108
+ constraints.
109
+ - Optional: Celery, to run the SLA job (`pip install "django-maker-checker[celery]"`). Without it, run a management
110
+ command from cron.
111
+
112
+ ---
113
+
114
+ ## Quick start
115
+
116
+ ### 1. Install
117
+
118
+ ```bash
119
+ pip install django-maker-checker
120
+ ```
121
+
122
+ ### 2. Configure
123
+
124
+ ```python
125
+ # settings.py
126
+ INSTALLED_APPS = [
127
+ ...,
128
+ "django_maker_checker",
129
+ "approvals_integration", # your app holding the adapter and guarded actions
130
+ ]
131
+
132
+ APPROVALS_TENANT_MODEL = "accounts.Organisation" # who owns rules and requests
133
+ APPROVALS_ROLE_MODEL = "accounts.Role" # a role that can be named as an approver
134
+ APPROVALS_ADAPTER = "approvals_integration.adapter.Approvals"
135
+ ```
136
+
137
+ The engine's app label is `approvals` (tables `approvals_*`). Run `python manage.py migrate`.
138
+
139
+ ### 3. Tell the engine about your users
140
+
141
+ The engine never imports your code. Everything it needs to know about tenants, roles, permissions, notifications and
142
+ auditing goes through one adapter class. For a project where each user has a `Membership` in one organisation with
143
+ one role:
144
+
145
+ ```python
146
+ # approvals_integration/adapter.py
147
+ from django.contrib.auth import get_user_model
148
+ from django.db.models import Q
149
+
150
+ from django_maker_checker.adapter import ApprovalsAdapter
151
+ from accounts.models import Membership, Role
152
+
153
+
154
+ class Approvals(ApprovalsAdapter):
155
+ def _membership(self, user):
156
+ return Membership.objects.filter(user=user, active=True).select_related("organisation", "role").first()
157
+
158
+ def tenant_of(self, user):
159
+ membership = self._membership(user)
160
+ return membership.organisation if membership and user.is_active else None
161
+
162
+ def members(self, tenant, user_ids=None, role_ids=None):
163
+ users = get_user_model().objects.filter(membership__organisation=tenant, membership__active=True, is_active=True)
164
+ if user_ids is not None or role_ids is not None:
165
+ users = users.filter(Q(pk__in=user_ids or ()) | Q(membership__role_id__in=role_ids or ()))
166
+ return users.distinct()
167
+
168
+ def role_ids_of(self, user):
169
+ membership = self._membership(user)
170
+ return {membership.role_id} if membership and membership.role_id else set()
171
+
172
+ def roles(self, tenant):
173
+ return Role.objects.filter(organisation=tenant)
174
+
175
+ def role_member_count(self, role):
176
+ return Membership.objects.filter(role=role, active=True).count()
177
+
178
+ def has_permission(self, user, codename):
179
+ return user.has_perm(f"accounts.{codename}")
180
+
181
+ def role_has_permission(self, role, codename):
182
+ return role.permissions.filter(codename=codename).exists()
183
+
184
+ def notify(self, notice):
185
+ ... # send notice.text to notice.recipients by email, in-app, chat
186
+
187
+ def audit(self, record):
188
+ ... # write the AuditRecord to your audit log
189
+ ```
190
+
191
+ ### 4. Guard an action
192
+
193
+ ```python
194
+ # approvals_integration/actions.py
195
+ from django_maker_checker.actions import approval_action
196
+
197
+
198
+ @approval_action(
199
+ "credit_limit_change",
200
+ label="Credit Limit Change",
201
+ category="Accounts",
202
+ lock_key=lambda p: f"account:{p['account_id']}", # at most one open request per account
203
+ target_label=lambda p: f"Account {p['account_id']}",
204
+ )
205
+ def change_credit_limit(*, tenant, maker, context, account_id, new_limit):
206
+ Account.objects.filter(pk=account_id, organisation=tenant).update(credit_limit=new_limit)
207
+ return {"account_id": account_id}
208
+ ```
209
+
210
+ Import the module from your app's `AppConfig.ready()` so the action is registered at start-up. Then call it like the
211
+ function it is:
212
+
213
+ ```python
214
+ outcome = change_credit_limit(
215
+ tenant=request.user.membership.organisation,
216
+ maker=request.user,
217
+ request=request,
218
+ justification="Annual review",
219
+ account_id=42,
220
+ new_limit="5000.00",
221
+ )
222
+ if outcome.pending:
223
+ messages.info(request, f"Sent for approval as {outcome.approval_request.reference}.")
224
+ ```
225
+
226
+ With no rule configured for the tenant the function simply runs, so adding maker-checker changes nothing until a tenant
227
+ switches it on.
228
+
229
+ ### 5. Switch on a rule and approve
230
+
231
+ ```python
232
+ from django_maker_checker import policies, services
233
+ from django_maker_checker.constants import Decision, WorkflowType
234
+
235
+ rule = policies.policy_for(organisation, "credit_limit_change")
236
+ policies.save_policy(
237
+ rule,
238
+ policies.PolicySpec(workflow_type=WorkflowType.SINGLE, approvers=[f"r:{risk_officer_role.pk}"]),
239
+ by=admin_user,
240
+ reason="New credit policy",
241
+ )
242
+
243
+ # later, a risk officer (not the maker) approves; the credit limit changes now
244
+ services.decide(approval_request.pk, risk_officer, Decision.APPROVE, "Within appetite")
245
+ ```
246
+
247
+ Approvers must hold the action's permission, `approve_credit_limit_change` here. The engine names the permission; you
248
+ create and grant it (see [Permissions](#permissions)).
249
+
250
+ ---
251
+
252
+ ## Concepts
253
+
254
+ | Term | Meaning |
255
+ |---|---|
256
+ | **Maker** | The user who performs the guarded action. |
257
+ | **Checker** | The user who approves or rejects. Never the maker of the same request, whatever their permissions. |
258
+ | **Approver** | A user, or every member of a role, named on a rule as allowed to act as checker. They must hold `approve_<action_type>`. |
259
+ | **Approval Policy (rule)** | The rule for one action type in one tenant: workflow, approvers, SLA, escalation backup and risk level. It applies tenant-wide (`scope_key == ""`) or to one host-defined scope (say, a department); a scoped rule wins. |
260
+ | **Approval Request** | One maker's proposed change. `PENDING → APPROVED → EXECUTED`, or `REJECTED` (with a reason), `CANCELLED`, or `FAILED` (approved, but applying it raised an error; it can be retried). |
261
+ | **Delegation** | A time-boxed hand-over: the delegate acts with the delegator's authority, and decisions record both people. |
262
+
263
+ Workflows:
264
+
265
+ | Workflow | Approval needed |
266
+ |---|---|
267
+ | Single Approver | One named user or role |
268
+ | Multiple Approvers | Any one of several, or all of them (`require_all`) |
269
+ | Approval Flow | Ordered steps; each step is decided before the next starts |
270
+
271
+ ## How a request flows
272
+
273
+ ```
274
+ Maker performs action
275
+ │
276
+ ▼
277
+ services.submit_or_execute(action_type, tenant=…, maker=…, payload=…)
278
+ │
279
+ ├── no enabled, configured rule ──► handler.execute() now
280
+ │
281
+ └── rule applies ──► ApprovalRequest(PENDING) + stages + events; approvers notified
282
+ │
283
+ ┌────────────── cancel ──┼── reject (reason required) ──► REJECTED
284
+ ▼ │
285
+ CANCELLED │ approve (each stage / approver)
286
+ ▼
287
+ APPROVED ──► handler.validate() + handler.execute()
288
+ │ │
289
+ ▼ ▼ exception
290
+ EXECUTED FAILED ──(retry, admin)──► APPROVED
291
+ ```
292
+
293
+ - **Status is a protected state machine** (django-fsm-2): only transition methods change it.
294
+ - **Rules are snapshotted.** Each request copies its rule when it is raised, so editing or deleting a rule never
295
+ changes requests already in flight.
296
+ - **One open request per item**, by a partial unique index on `(tenant, action_type, lock_key)`.
297
+ - **Execution is separate from the decision.** If applying fails, the approval stands and the request is `FAILED`.
298
+ - **Every write locks the row** (`select_for_update`), so two checkers approving at once can't apply a change twice.
299
+ Notifications are sent after commit.
300
+
301
+ ### Who may decide
302
+
303
+ `permissions.decision_slot(user, request)` is the single source of truth. The user must:
304
+
305
+ 1. be an active member of the request's tenant;
306
+ 2. not be the maker (no superuser or staff exception);
307
+ 3. not have acted on this request already;
308
+ 4. hold `approve_<action_type>`;
309
+ 5. fill an open slot on the **current** stage: they are the named user, hold the named role, or are the escalation
310
+ approver.
311
+
312
+ A delegate satisfies 4 and 5 through the delegator.
313
+
314
+ ### SLA and escalation
315
+
316
+ `services.process_sla()` sends one reminder at 75% of a step's window. When the window has passed, it adds the rule's
317
+ escalation backup as an approver on the step, once. It is safe to run concurrently and repeatedly. Schedule it every
318
+ few minutes, either:
319
+
320
+ - with Celery beat: the task `approvals.tasks.process_approval_slas` (needs the `celery` extra), or
321
+ - with cron: `python manage.py process_approval_slas`.
322
+
323
+ ### Audit trail
324
+
325
+ `ApprovalEvent` rows form each request's timeline: submitted, notified, viewed, SLA warning, escalated, step approved,
326
+ approved, rejected, cancelled, comment, executed, failed, retried. Every transition, delegation change and rule change
327
+ is also passed to `adapter.audit()` as an `AuditRecord`.
328
+
329
+ ### Rule history
330
+
331
+ Changing a rule changes the control itself, so rules are versioned. Every create, update, enable/disable and delete
332
+ through `policies` writes an append-only `ApprovalPolicyRevision` in the same transaction: the version, the change
333
+ type, a field-by-field before/after (`SLA: Off → 4 hours`), the full rule, who made it and why. History survives
334
+ deletion, and each request records the version it was raised under (`queries.policy_version_status(request)` tells a
335
+ UI whether the rule has changed since).
336
+
337
+ ---
338
+
339
+ ## Integrating with your project
340
+
341
+ ### The adapter
342
+
343
+ | Method | Answers |
344
+ |---|---|
345
+ | `tenant_of(user)` | The tenant the user acts for, or None if they are not an active member. |
346
+ | `members(tenant, user_ids=None, role_ids=None)` | Active users of the tenant, optionally only those with the given ids or roles. |
347
+ | `role_ids_of(user)` / `roles(tenant)` / `role_member_count(role)` | Role membership, and the roles that can be picked as approvers. |
348
+ | `has_permission(user, codename)` / `role_has_permission(role, codename)` | Your permission check. |
349
+ | `notify(notice)` | Deliver a `Notice` (requested, decided, escalated, cancelled, SLA warning). Called after commit; failures are logged and never undo a decision. |
350
+ | `audit(record)` | Write an `AuditRecord` to your audit log. |
351
+ | `is_enabled(tenant)` | Optional. Whether maker-checker is switched on for the tenant (default `True`). |
352
+ | `scope_label`, `request_url`, `user_label`, `role_label` | Optional presentation helpers. |
353
+
354
+ ### Permissions
355
+
356
+ The engine only names the permission codenames it checks: `approve_<action_type>` for each action
357
+ (`ActionHandler.approve_permission`) and `permissions.MANAGE_POLICIES` for administrators. Your project creates and
358
+ grants them, for example with a `post_migrate` handler that loops over `registry.all_handlers()`.
359
+
360
+ ### Switching maker-checker on per tenant
361
+
362
+ Override `is_enabled(tenant)` to put the feature behind a feature flag, a plan or a setting. While it returns False the
363
+ tenant is treated as having no rules: guarded actions run immediately, `requires_approval` is False, `submit` refuses,
364
+ and the SLA job skips the tenant. Rules, history and requests are kept, so switching back on resumes where it stopped.
365
+ Requests that are open at the switch stay open and can still be cancelled.
366
+
367
+ ### Migration order
368
+
369
+ Django resolves `APPROVALS_TENANT_MODEL` and `APPROVALS_ROLE_MODEL` to their app's *first* migration. If those models
370
+ are created in a later migration, add an empty migration to your integration app that depends on it and runs before
371
+ the engine:
372
+
373
+ ```python
374
+ class Migration(migrations.Migration):
375
+ dependencies = [("accounts", "0007_role")]
376
+ run_before = [("approvals", "0001_initial")]
377
+ operations = []
378
+ ```
379
+
380
+ As with `AUTH_USER_MODEL`, choose the tenant and role models before the first migration; changing them later is not
381
+ supported.
382
+
383
+ ---
384
+
385
+ ## Defining actions
386
+
387
+ Whether an action needs approval is data. Code only describes how the action is captured and applied. Pick the
388
+ lightest option that fits:
389
+
390
+ | The action is… | Use | You write |
391
+ |---|---|---|
392
+ | A service function you can call with plain arguments | `@approval_action` (`actions`) | A decorator |
393
+ | A change to some fields of one model record | `ModelChangeHandler` (`model_change`) | A subclass naming the model and fields |
394
+ | Anything else: files, many records, external systems | `ActionHandler` (`registry`) | A full handler class |
395
+
396
+ In every case each action is one handler registered under its `action_type`, the call site is the same whether or not
397
+ a rule applies, and payloads must survive a JSON round trip (`Decimal` and dates arrive as strings).
398
+
399
+ ### `@approval_action`: defer a function call
400
+
401
+ ```python
402
+ @approval_action(
403
+ "balance_update",
404
+ label="Balance Update",
405
+ category="Balances",
406
+ lock_key=lambda p: f"{p['account_id']}:{p['balance_date']}",
407
+ target_label=lambda p: f"Account {p['account_id']} on {p['balance_date']}",
408
+ snapshot_before=lambda tenant, p: {"new_balance": current_balance(tenant, p)}, # "Current" in the review panel
409
+ scopes=(Scope.GLOBAL, Scope.SCOPED),
410
+ scope_keys=lambda tenant, p: [f"department:{department_of(p['account_id'])}"],
411
+ matches=lambda policy, p: amount_above(policy.conditions.get("amount_gt"), p), # optional thresholds
412
+ )
413
+ def update_balance(*, tenant, maker, context, account_id, balance_date, new_balance):
414
+ ... # runs as the maker's change; context.actor is the final checker
415
+ ```
416
+
417
+ `update_balance.apply(...)` calls the undecorated function, for tests and admin tools that must bypass approval.
418
+
419
+ ### `ModelChangeHandler`: defer a record edit
420
+
421
+ ```python
422
+ @register
423
+ class CreditLimitChangeHandler(ModelChangeHandler):
424
+ action_type = "credit_limit_change"
425
+ label = "Credit Limit Change"
426
+ category = "Accounts"
427
+ model = Account
428
+ fields = ("credit_limit",) # nothing else can change through this action
429
+ tenant_field = "organisation" # the model's foreign key to the tenant (default "tenant")
430
+
431
+
432
+ outcome = CreditLimitChangeHandler.submit_change(account, {"credit_limit": "5000.00"}, maker=request.user)
433
+ ```
434
+
435
+ The base class takes care of the lock per record, the snapshot of the changed fields, an optimistic check that the
436
+ record hasn't changed since submission, field conversion, validation and save, and Current/Proposed review rows.
437
+
438
+ ### `ActionHandler`: full control
439
+
440
+ ```python
441
+ @register
442
+ class PaymentRunHandler(ActionHandler):
443
+ action_type = "payment_run"
444
+ label = "Payment Run"
445
+ category = "Payments"
446
+
447
+ def lock_key(self, payload): ... # required: identifies the item
448
+ def target_label(self, payload): ...
449
+ def snapshot_before(self, tenant, payload): ...
450
+ def scope_keys(self, tenant, payload): ... # for scoped rules, most specific first
451
+ def matches(self, policy, payload): ... # thresholds from policy.conditions
452
+ def validate(self, approval_request): ... # re-checked right before applying
453
+ def execute(self, approval_request, context): ... # apply; return a JSON-safe result
454
+ def review_context(self, approval_request): ... # data for your review panel
455
+ ```
456
+
457
+ Call it with `services.submit_or_execute(action_type, tenant=…, maker=…, payload=…, justification=…, request=…)`.
458
+ `execute()` runs in a transaction: raise to roll back and record `FAILED`, queue background work with
459
+ `transaction.on_commit`, and make writes to external systems safe to retry.
460
+
461
+ ---
462
+
463
+ ## Building a UI
464
+
465
+ `queries` returns everything an approvals page needs, already filtered by what the viewer may see:
466
+
467
+ | Function | For |
468
+ |---|---|
469
+ | `awaiting(user, filter)` | Requests waiting for the user's decision now |
470
+ | `made_by(user, open_only, filter)` | The user's own requests |
471
+ | `checker_history(user, filter)` | Decided requests the user may review |
472
+ | `pending_count(user)` | A badge (cached per tenant) |
473
+ | `allowed_actions(user, request)` | Which buttons to show |
474
+ | `sla_status(request)` / `stage_progress(request)` | Progress bars and step strips |
475
+ | `policy_history(tenant, action_type)` | A rule's revisions |
476
+
477
+ `RequestFilter` filters by status, action, risk, maker, dates and free text. Each handler's `review_template` and
478
+ `review_context()` describe its review panel.
479
+
480
+ ## Public API
481
+
482
+ | Module | Use it for |
483
+ |---|---|
484
+ | `services` | Writes: `submit_or_execute`, `submit`, `decide`, `cancel`, `retry`, `add_comment`, `record_view`, `delegate`, `revoke_delegation`, `process_sla`, `find_policy`, `requires_approval` |
485
+ | `policies` | Rules: `policy_for`, `save_policy`, `add_scoped_policy`, `delete_policy`, `describe`, `current_version` (invalid input raises `PolicyError` with displayable messages) |
486
+ | `queries` | Reads for UIs (above) |
487
+ | `permissions` | `can_decide`, `can_cancel`, `can_retry`, `can_view`, `can_manage_policies`, `approver_choices` |
488
+ | `registry`, `actions`, `model_change` | Defining actions |
489
+ | `models` | Read-only access; change state only through `services` and `policies` |
490
+ | `adapter` | `ApprovalsAdapter`, `Notice`, `AuditRecord` |
491
+
492
+ To react to outcomes, use `django_fsm.signals.post_transition` with `sender=ApprovalRequest` (it fires inside the
493
+ transaction, before the save, so use `transaction.on_commit`), or `post_save` on `ApprovalEvent`.
494
+
495
+ ## Settings
496
+
497
+ | Setting | Default | |
498
+ |---|---|---|
499
+ | `APPROVALS_TENANT_MODEL` | required | `"app_label.Model"` of the tenant |
500
+ | `APPROVALS_ROLE_MODEL` | required | `"app_label.Model"` of a role that can be named as an approver |
501
+ | `APPROVALS_ADAPTER` | required | Dotted path of your `ApprovalsAdapter` subclass |
502
+ | `APPROVALS_CACHE_ALIAS` | `"default"` | Cache for pending counts |
503
+ | `APPROVALS_CACHE_TIMEOUT` | `60` | Seconds a pending count is cached |
504
+ | `APPROVALS_REFERENCE_PREFIX` | `"REQ"` | Prefix of request references, as in `REQ-2610-00042` |
505
+
506
+ ## Known limits
507
+
508
+ - No built-in condition language: thresholds go in `ApprovalPolicy.conditions` and are read by `ActionHandler.matches()`.
509
+ - Approval strategies are one, any one of several, all, or ordered steps. There are no N-of-M quorums or org-chart
510
+ routing yet.
511
+ - Messages are in English and not yet translatable.
512
+
513
+ ## Contributing
514
+
515
+ Contributions are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md) and the
516
+ [development guide](https://django-maker-checker.readthedocs.io/en/latest/development.html). The tests are written as a specification of the
517
+ library: start with [tests/README.md](tests/README.md).
518
+
519
+ ## License
520
+
521
+ [MIT](LICENSE)