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.
- django_maker_checker-0.4.0/LICENSE +21 -0
- django_maker_checker-0.4.0/PKG-INFO +521 -0
- django_maker_checker-0.4.0/README.md +468 -0
- django_maker_checker-0.4.0/django_maker_checker/__init__.py +3 -0
- django_maker_checker-0.4.0/django_maker_checker/actions.py +171 -0
- django_maker_checker-0.4.0/django_maker_checker/adapter.py +115 -0
- django_maker_checker-0.4.0/django_maker_checker/admin.py +86 -0
- django_maker_checker-0.4.0/django_maker_checker/apps.py +14 -0
- django_maker_checker-0.4.0/django_maker_checker/audit.py +17 -0
- django_maker_checker-0.4.0/django_maker_checker/conf.py +54 -0
- django_maker_checker-0.4.0/django_maker_checker/constants.py +126 -0
- django_maker_checker-0.4.0/django_maker_checker/exceptions.py +21 -0
- django_maker_checker-0.4.0/django_maker_checker/management/__init__.py +0 -0
- django_maker_checker-0.4.0/django_maker_checker/management/commands/__init__.py +0 -0
- django_maker_checker-0.4.0/django_maker_checker/management/commands/process_approval_slas.py +20 -0
- django_maker_checker-0.4.0/django_maker_checker/migrations/0001_initial.py +193 -0
- django_maker_checker-0.4.0/django_maker_checker/migrations/0002_policy_revisions.py +44 -0
- django_maker_checker-0.4.0/django_maker_checker/migrations/__init__.py +0 -0
- django_maker_checker-0.4.0/django_maker_checker/model_change.py +127 -0
- django_maker_checker-0.4.0/django_maker_checker/models.py +424 -0
- django_maker_checker-0.4.0/django_maker_checker/notify.py +179 -0
- django_maker_checker-0.4.0/django_maker_checker/permissions.py +193 -0
- django_maker_checker-0.4.0/django_maker_checker/policies.py +333 -0
- django_maker_checker-0.4.0/django_maker_checker/py.typed +0 -0
- django_maker_checker-0.4.0/django_maker_checker/queries.py +371 -0
- django_maker_checker-0.4.0/django_maker_checker/registry.py +167 -0
- django_maker_checker-0.4.0/django_maker_checker/services.py +681 -0
- django_maker_checker-0.4.0/django_maker_checker/tasks.py +22 -0
- django_maker_checker-0.4.0/django_maker_checker.egg-info/PKG-INFO +521 -0
- django_maker_checker-0.4.0/django_maker_checker.egg-info/SOURCES.txt +45 -0
- django_maker_checker-0.4.0/django_maker_checker.egg-info/dependency_links.txt +1 -0
- django_maker_checker-0.4.0/django_maker_checker.egg-info/requires.txt +22 -0
- django_maker_checker-0.4.0/django_maker_checker.egg-info/top_level.txt +1 -0
- django_maker_checker-0.4.0/pyproject.toml +124 -0
- django_maker_checker-0.4.0/setup.cfg +4 -0
- django_maker_checker-0.4.0/tests/test_cancelling_and_the_timeline.py +82 -0
- django_maker_checker-0.4.0/tests/test_configuring_rules.py +132 -0
- django_maker_checker-0.4.0/tests/test_deciding.py +170 -0
- django_maker_checker-0.4.0/tests/test_defining_actions.py +164 -0
- django_maker_checker-0.4.0/tests/test_delegating.py +96 -0
- django_maker_checker-0.4.0/tests/test_host_contract.py +104 -0
- django_maker_checker-0.4.0/tests/test_notifications.py +93 -0
- django_maker_checker-0.4.0/tests/test_queues_and_display.py +134 -0
- django_maker_checker-0.4.0/tests/test_rule_history.py +88 -0
- django_maker_checker-0.4.0/tests/test_sla_and_escalation.py +59 -0
- django_maker_checker-0.4.0/tests/test_submitting.py +115 -0
- 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
|
+
[](https://github.com/joemash/django-maker-checker/actions/workflows/ci.yml)
|
|
57
|
+
[](https://pypi.org/project/django-maker-checker/)
|
|
58
|
+
[](https://pypi.org/project/django-maker-checker/)
|
|
59
|
+
[](https://django-maker-checker.readthedocs.io)
|
|
60
|
+
[](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)
|