django-approvals 0.1.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_approvals-0.1.0/LICENSE +21 -0
- django_approvals-0.1.0/PKG-INFO +241 -0
- django_approvals-0.1.0/README.md +213 -0
- django_approvals-0.1.0/django_approve/__init__.py +19 -0
- django_approvals-0.1.0/django_approve/admin/__init__.py +5 -0
- django_approvals-0.1.0/django_approve/admin/approval_config.py +19 -0
- django_approvals-0.1.0/django_approve/admin/change_request.py +140 -0
- django_approvals-0.1.0/django_approve/admin/filters.py +18 -0
- django_approvals-0.1.0/django_approve/admin/forms.py +60 -0
- django_approvals-0.1.0/django_approve/admin/mixins.py +83 -0
- django_approvals-0.1.0/django_approve/apps.py +20 -0
- django_approvals-0.1.0/django_approve/config.py +24 -0
- django_approvals-0.1.0/django_approve/cons.py +15 -0
- django_approvals-0.1.0/django_approve/exceptions.py +16 -0
- django_approvals-0.1.0/django_approve/fields.py +54 -0
- django_approvals-0.1.0/django_approve/middlewares.py +48 -0
- django_approvals-0.1.0/django_approve/migrations/0001_initial.py +104 -0
- django_approvals-0.1.0/django_approve/migrations/__init__.py +0 -0
- django_approvals-0.1.0/django_approve/models/__init__.py +4 -0
- django_approvals-0.1.0/django_approve/models/approval_config.py +16 -0
- django_approvals-0.1.0/django_approve/models/change_request.py +42 -0
- django_approvals-0.1.0/django_approve/registry.py +71 -0
- django_approvals-0.1.0/django_approve/serializers.py +30 -0
- django_approvals-0.1.0/django_approve/services.py +48 -0
- django_approvals-0.1.0/django_approve/signals.py +78 -0
- django_approvals-0.1.0/django_approve/templates/django_approve/target_change_form.html +34 -0
- django_approvals-0.1.0/pyproject.toml +150 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Denis Novikov
|
|
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,241 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-approvals
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Moderate edits in the Django admin: changes to tracked model fields wait for a second person's approval (four-eyes / maker-checker)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: django,admin,approval,maker-checker,four-eyes,workflow,moderator
|
|
8
|
+
Author: Denis Novikov
|
|
9
|
+
Author-email: alpden550@gmail.com
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 5.0
|
|
15
|
+
Classifier: Framework :: Django :: 5.1
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Dist: django (>=5)
|
|
23
|
+
Project-URL: Homepage, https://github.com/alpden550/django-approve
|
|
24
|
+
Project-URL: Issues, https://github.com/alpden550/django-approve/issues
|
|
25
|
+
Project-URL: Repository, https://github.com/alpden550/django-approve
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# django-approvals
|
|
29
|
+
|
|
30
|
+
> Moderate edits in the Django admin — a change to a tracked model field isn't
|
|
31
|
+
> saved directly, it waits for a second person's approval (four-eyes /
|
|
32
|
+
> maker-checker).
|
|
33
|
+
|
|
34
|
+
[](https://github.com/alpden550/django-approve/actions/workflows/ci.yml)
|
|
35
|
+
[](https://www.python.org/)
|
|
36
|
+
[](https://www.djangoproject.com/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
|
|
39
|
+
**Granularity is per field, not per object.** A single save touching three
|
|
40
|
+
tracked fields creates three independent requests, each with its own status and
|
|
41
|
+
its own reviewer. There is no batch / "change set" model — grouping is purely a
|
|
42
|
+
UX artifact (one "Submitted for approval: a, b, c" message).
|
|
43
|
+
|
|
44
|
+
## How it works
|
|
45
|
+
|
|
46
|
+
1. **Register** a model to make its fields *eligible* for approval.
|
|
47
|
+
2. **Pick** which eligible fields are actually *tracked*, in the admin.
|
|
48
|
+
3. **Add the admin mixin.** Editing a tracked field now creates an approval
|
|
49
|
+
request instead of writing the value.
|
|
50
|
+
4. A **reviewer** approves or rejects each request — per field, independently.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install django-approvals
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
INSTALLED_APPS = [
|
|
60
|
+
"django.contrib.contenttypes",
|
|
61
|
+
"django_approve",
|
|
62
|
+
]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Run `migrate`. This creates the `ApprovalConfig` / `ChangeRequestField` tables,
|
|
66
|
+
syncs an `ApprovalConfig` row per registered model, and creates the `Approvals`
|
|
67
|
+
group with `view` / `change` permissions on both models.
|
|
68
|
+
|
|
69
|
+
Optionally, add the middleware to show reviewers an *"N change request(s)
|
|
70
|
+
awaiting review"* banner on the admin index:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
MIDDLEWARE = [
|
|
74
|
+
"django_approve.middlewares.PendingApprovalsNoticeMiddleware",
|
|
75
|
+
]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
It only fires on `GET /admin/`, for active users in the `Approvals` group, and
|
|
79
|
+
only when at least one `pending` request exists.
|
|
80
|
+
|
|
81
|
+
## Usage
|
|
82
|
+
|
|
83
|
+
### 1. Register a model
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from django_approve.registry import register
|
|
87
|
+
|
|
88
|
+
@register
|
|
89
|
+
class Employee(models.Model):
|
|
90
|
+
name = models.CharField(max_length=255)
|
|
91
|
+
salary = models.DecimalField(max_digits=10, decimal_places=2)
|
|
92
|
+
manager = models.ForeignKey("self", null=True, on_delete=models.SET_NULL)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Bare `@register` makes *every* eligible field a candidate. A field is eligible
|
|
96
|
+
when it is concrete and editable, and is **not**:
|
|
97
|
+
|
|
98
|
+
- the primary key,
|
|
99
|
+
- non-editable,
|
|
100
|
+
- an `auto_now` / `auto_now_add` timestamp,
|
|
101
|
+
- a `FileField` / `ImageField` (files and M2M are out of scope for v1).
|
|
102
|
+
|
|
103
|
+
To narrow the set further, pass `fields` — it is intersected with the eligible
|
|
104
|
+
candidates:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
@register(fields=["salary", "manager"])
|
|
108
|
+
class Employee(models.Model):
|
|
109
|
+
...
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Registering only makes a field *eligible* — nothing is tracked yet.
|
|
113
|
+
|
|
114
|
+
### 2. Pick tracked fields in the admin
|
|
115
|
+
|
|
116
|
+
Each registered model gets an `ApprovalConfig` row (synced automatically on
|
|
117
|
+
`migrate`). In the `ApprovalConfig` admin, check which candidate fields should
|
|
118
|
+
actually go through the approval flow — this is `tracked_fields`, a subset of
|
|
119
|
+
the candidates. Rows can't be added or deleted by hand; they only come from the
|
|
120
|
+
sync.
|
|
121
|
+
|
|
122
|
+
### 3. Add the admin mixin
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from django_approve import ApprovalAdminMixin
|
|
126
|
+
|
|
127
|
+
@admin.register(Employee)
|
|
128
|
+
class EmployeeAdmin(ApprovalAdminMixin, admin.ModelAdmin):
|
|
129
|
+
...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
From here on, editing a tracked field through this admin no longer writes it
|
|
133
|
+
directly:
|
|
134
|
+
|
|
135
|
+
- The change is diverted into a `ChangeRequestField(status=pending)` with the
|
|
136
|
+
old / new value serialized, and the in-memory value is reverted before
|
|
137
|
+
saving. Untracked fields save normally in the same request.
|
|
138
|
+
- While a request is pending, the field is locked (`get_readonly_fields`) and
|
|
139
|
+
the change form shows a "Pending approval" block above it.
|
|
140
|
+
- A reviewer (member of the `Approvals` group) sees a banner on the admin
|
|
141
|
+
index, then works through pending rows in the `ChangeRequestField` changelist
|
|
142
|
+
— **Approve** or **Reject**, per field, independently. Both are also
|
|
143
|
+
available as bulk actions: select multiple pending rows and run **Approve
|
|
144
|
+
selected** / **Reject selected** in one go.
|
|
145
|
+
|
|
146
|
+
See [Screenshots](#screenshots) for what this looks like in the admin.
|
|
147
|
+
|
|
148
|
+
> [!WARNING]
|
|
149
|
+
> **Locking only happens in the admin.** The whole flow — diverting edits,
|
|
150
|
+
> locking fields, showing the pending block — lives in `ApprovalAdminMixin`.
|
|
151
|
+
> Calling `.save()` from code (management commands, Celery tasks, shell, DRF)
|
|
152
|
+
> bypasses it entirely and writes straight to the row. For the same guarantee
|
|
153
|
+
> outside the admin, call `apply_field` yourself or add your own guard — there
|
|
154
|
+
> is no model-level enforcement.
|
|
155
|
+
|
|
156
|
+
## Statuses
|
|
157
|
+
|
|
158
|
+
| Status | Meaning |
|
|
159
|
+
| ----------- | ------------------------------------------------------------------------------------------------------------ |
|
|
160
|
+
| `pending` | Awaiting review. Field is locked. |
|
|
161
|
+
| `approved` | Applied to the target in the same atomic transaction as the status change. There is no separate "applied" state. |
|
|
162
|
+
| `rejected` | Reviewer declined the change. Reviewer-only verb. |
|
|
163
|
+
| `cancelled` | The author withdrew the request. Author-only verb. |
|
|
164
|
+
| `deleted` | The target was deleted while the request was pending. Set automatically via `post_delete`; never a manual choice. |
|
|
165
|
+
|
|
166
|
+
A pending request can only move forward, and the role restricts the available
|
|
167
|
+
choices:
|
|
168
|
+
|
|
169
|
+
- the **author** can `cancel`, but never `approve` / `reject` their own request
|
|
170
|
+
(when `APPROVE_REQUIRE_DIFFERENT_USER` is on);
|
|
171
|
+
- a **reviewer** can `approve` / `reject`, but not `cancel` someone else's
|
|
172
|
+
request.
|
|
173
|
+
|
|
174
|
+
If the target's current value no longer matches the recorded `old_value` at
|
|
175
|
+
approval time (someone else changed it in the meantime), approval fails with a
|
|
176
|
+
`ConflictError` shown as an admin message — the request stays `pending` and
|
|
177
|
+
nothing is applied.
|
|
178
|
+
|
|
179
|
+
## Settings
|
|
180
|
+
|
|
181
|
+
All settings are optional; defaults are shown.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
APPROVE_AUTO_CREATE_GROUP = True # create/maintain the Approvals group via post_migrate
|
|
185
|
+
APPROVE_GROUP_NAME = "Approvals" # group name; membership = reviewer
|
|
186
|
+
APPROVE_REQUIRE_DIFFERENT_USER = True # four-eyes: block self-approval (SelfApprovalError)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`APPROVE_AUTO_CREATE_GROUP` only controls whether the package manages the
|
|
190
|
+
group's permissions on `migrate`; it never adds or removes users.
|
|
191
|
+
|
|
192
|
+
## Supported field types (v1)
|
|
193
|
+
|
|
194
|
+
Any concrete, editable field is supported, with two serialization paths:
|
|
195
|
+
|
|
196
|
+
- **Relations** (`ForeignKey`, `OneToOneField`) — stored as the related
|
|
197
|
+
object's `.pk`, restored via `related_model._base_manager.get(pk=...)`; raises
|
|
198
|
+
`ConflictError` instead of `DoesNotExist` if the target was deleted before
|
|
199
|
+
approval.
|
|
200
|
+
- **Everything else** — stored via `field.get_prep_value()` encoded with
|
|
201
|
+
`DjangoJSONEncoder` (covers `str` / `int` / `bool`, `Decimal`, `date` /
|
|
202
|
+
`datetime` / `time` / `timedelta`, `UUID`, `JSONField`, …), restored via
|
|
203
|
+
`field.to_python()`.
|
|
204
|
+
|
|
205
|
+
Out of scope for v1: `FileField` / `ImageField`, `ManyToManyField`, and (as for
|
|
206
|
+
any tracked field) the primary key, non-editable, and `auto_now` /
|
|
207
|
+
`auto_now_add` fields.
|
|
208
|
+
|
|
209
|
+
## Screenshots
|
|
210
|
+
|
|
211
|
+
<details>
|
|
212
|
+
<summary>ApprovalConfig: pick tracked fields per model</summary>
|
|
213
|
+
|
|
214
|
+

|
|
215
|
+

|
|
216
|
+
|
|
217
|
+
</details>
|
|
218
|
+
|
|
219
|
+
<details>
|
|
220
|
+
<summary>Locked field and pending-approval block on the change form</summary>
|
|
221
|
+
|
|
222
|
+

|
|
223
|
+
|
|
224
|
+
</details>
|
|
225
|
+
|
|
226
|
+
<details>
|
|
227
|
+
<summary>Reviewer: admin-index banner + ChangeRequestField changelist</summary>
|
|
228
|
+
|
|
229
|
+

|
|
230
|
+

|
|
231
|
+
|
|
232
|
+
</details>
|
|
233
|
+
|
|
234
|
+
## Development
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
poetry install
|
|
238
|
+
poetry run pytest
|
|
239
|
+
poetry run ruff check .
|
|
240
|
+
```
|
|
241
|
+
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# django-approvals
|
|
2
|
+
|
|
3
|
+
> Moderate edits in the Django admin — a change to a tracked model field isn't
|
|
4
|
+
> saved directly, it waits for a second person's approval (four-eyes /
|
|
5
|
+
> maker-checker).
|
|
6
|
+
|
|
7
|
+
[](https://github.com/alpden550/django-approve/actions/workflows/ci.yml)
|
|
8
|
+
[](https://www.python.org/)
|
|
9
|
+
[](https://www.djangoproject.com/)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
|
|
12
|
+
**Granularity is per field, not per object.** A single save touching three
|
|
13
|
+
tracked fields creates three independent requests, each with its own status and
|
|
14
|
+
its own reviewer. There is no batch / "change set" model — grouping is purely a
|
|
15
|
+
UX artifact (one "Submitted for approval: a, b, c" message).
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
1. **Register** a model to make its fields *eligible* for approval.
|
|
20
|
+
2. **Pick** which eligible fields are actually *tracked*, in the admin.
|
|
21
|
+
3. **Add the admin mixin.** Editing a tracked field now creates an approval
|
|
22
|
+
request instead of writing the value.
|
|
23
|
+
4. A **reviewer** approves or rejects each request — per field, independently.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install django-approvals
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
INSTALLED_APPS = [
|
|
33
|
+
"django.contrib.contenttypes",
|
|
34
|
+
"django_approve",
|
|
35
|
+
]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Run `migrate`. This creates the `ApprovalConfig` / `ChangeRequestField` tables,
|
|
39
|
+
syncs an `ApprovalConfig` row per registered model, and creates the `Approvals`
|
|
40
|
+
group with `view` / `change` permissions on both models.
|
|
41
|
+
|
|
42
|
+
Optionally, add the middleware to show reviewers an *"N change request(s)
|
|
43
|
+
awaiting review"* banner on the admin index:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
MIDDLEWARE = [
|
|
47
|
+
"django_approve.middlewares.PendingApprovalsNoticeMiddleware",
|
|
48
|
+
]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
It only fires on `GET /admin/`, for active users in the `Approvals` group, and
|
|
52
|
+
only when at least one `pending` request exists.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
### 1. Register a model
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from django_approve.registry import register
|
|
60
|
+
|
|
61
|
+
@register
|
|
62
|
+
class Employee(models.Model):
|
|
63
|
+
name = models.CharField(max_length=255)
|
|
64
|
+
salary = models.DecimalField(max_digits=10, decimal_places=2)
|
|
65
|
+
manager = models.ForeignKey("self", null=True, on_delete=models.SET_NULL)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Bare `@register` makes *every* eligible field a candidate. A field is eligible
|
|
69
|
+
when it is concrete and editable, and is **not**:
|
|
70
|
+
|
|
71
|
+
- the primary key,
|
|
72
|
+
- non-editable,
|
|
73
|
+
- an `auto_now` / `auto_now_add` timestamp,
|
|
74
|
+
- a `FileField` / `ImageField` (files and M2M are out of scope for v1).
|
|
75
|
+
|
|
76
|
+
To narrow the set further, pass `fields` — it is intersected with the eligible
|
|
77
|
+
candidates:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
@register(fields=["salary", "manager"])
|
|
81
|
+
class Employee(models.Model):
|
|
82
|
+
...
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Registering only makes a field *eligible* — nothing is tracked yet.
|
|
86
|
+
|
|
87
|
+
### 2. Pick tracked fields in the admin
|
|
88
|
+
|
|
89
|
+
Each registered model gets an `ApprovalConfig` row (synced automatically on
|
|
90
|
+
`migrate`). In the `ApprovalConfig` admin, check which candidate fields should
|
|
91
|
+
actually go through the approval flow — this is `tracked_fields`, a subset of
|
|
92
|
+
the candidates. Rows can't be added or deleted by hand; they only come from the
|
|
93
|
+
sync.
|
|
94
|
+
|
|
95
|
+
### 3. Add the admin mixin
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from django_approve import ApprovalAdminMixin
|
|
99
|
+
|
|
100
|
+
@admin.register(Employee)
|
|
101
|
+
class EmployeeAdmin(ApprovalAdminMixin, admin.ModelAdmin):
|
|
102
|
+
...
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
From here on, editing a tracked field through this admin no longer writes it
|
|
106
|
+
directly:
|
|
107
|
+
|
|
108
|
+
- The change is diverted into a `ChangeRequestField(status=pending)` with the
|
|
109
|
+
old / new value serialized, and the in-memory value is reverted before
|
|
110
|
+
saving. Untracked fields save normally in the same request.
|
|
111
|
+
- While a request is pending, the field is locked (`get_readonly_fields`) and
|
|
112
|
+
the change form shows a "Pending approval" block above it.
|
|
113
|
+
- A reviewer (member of the `Approvals` group) sees a banner on the admin
|
|
114
|
+
index, then works through pending rows in the `ChangeRequestField` changelist
|
|
115
|
+
— **Approve** or **Reject**, per field, independently. Both are also
|
|
116
|
+
available as bulk actions: select multiple pending rows and run **Approve
|
|
117
|
+
selected** / **Reject selected** in one go.
|
|
118
|
+
|
|
119
|
+
See [Screenshots](#screenshots) for what this looks like in the admin.
|
|
120
|
+
|
|
121
|
+
> [!WARNING]
|
|
122
|
+
> **Locking only happens in the admin.** The whole flow — diverting edits,
|
|
123
|
+
> locking fields, showing the pending block — lives in `ApprovalAdminMixin`.
|
|
124
|
+
> Calling `.save()` from code (management commands, Celery tasks, shell, DRF)
|
|
125
|
+
> bypasses it entirely and writes straight to the row. For the same guarantee
|
|
126
|
+
> outside the admin, call `apply_field` yourself or add your own guard — there
|
|
127
|
+
> is no model-level enforcement.
|
|
128
|
+
|
|
129
|
+
## Statuses
|
|
130
|
+
|
|
131
|
+
| Status | Meaning |
|
|
132
|
+
| ----------- | ------------------------------------------------------------------------------------------------------------ |
|
|
133
|
+
| `pending` | Awaiting review. Field is locked. |
|
|
134
|
+
| `approved` | Applied to the target in the same atomic transaction as the status change. There is no separate "applied" state. |
|
|
135
|
+
| `rejected` | Reviewer declined the change. Reviewer-only verb. |
|
|
136
|
+
| `cancelled` | The author withdrew the request. Author-only verb. |
|
|
137
|
+
| `deleted` | The target was deleted while the request was pending. Set automatically via `post_delete`; never a manual choice. |
|
|
138
|
+
|
|
139
|
+
A pending request can only move forward, and the role restricts the available
|
|
140
|
+
choices:
|
|
141
|
+
|
|
142
|
+
- the **author** can `cancel`, but never `approve` / `reject` their own request
|
|
143
|
+
(when `APPROVE_REQUIRE_DIFFERENT_USER` is on);
|
|
144
|
+
- a **reviewer** can `approve` / `reject`, but not `cancel` someone else's
|
|
145
|
+
request.
|
|
146
|
+
|
|
147
|
+
If the target's current value no longer matches the recorded `old_value` at
|
|
148
|
+
approval time (someone else changed it in the meantime), approval fails with a
|
|
149
|
+
`ConflictError` shown as an admin message — the request stays `pending` and
|
|
150
|
+
nothing is applied.
|
|
151
|
+
|
|
152
|
+
## Settings
|
|
153
|
+
|
|
154
|
+
All settings are optional; defaults are shown.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
APPROVE_AUTO_CREATE_GROUP = True # create/maintain the Approvals group via post_migrate
|
|
158
|
+
APPROVE_GROUP_NAME = "Approvals" # group name; membership = reviewer
|
|
159
|
+
APPROVE_REQUIRE_DIFFERENT_USER = True # four-eyes: block self-approval (SelfApprovalError)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`APPROVE_AUTO_CREATE_GROUP` only controls whether the package manages the
|
|
163
|
+
group's permissions on `migrate`; it never adds or removes users.
|
|
164
|
+
|
|
165
|
+
## Supported field types (v1)
|
|
166
|
+
|
|
167
|
+
Any concrete, editable field is supported, with two serialization paths:
|
|
168
|
+
|
|
169
|
+
- **Relations** (`ForeignKey`, `OneToOneField`) — stored as the related
|
|
170
|
+
object's `.pk`, restored via `related_model._base_manager.get(pk=...)`; raises
|
|
171
|
+
`ConflictError` instead of `DoesNotExist` if the target was deleted before
|
|
172
|
+
approval.
|
|
173
|
+
- **Everything else** — stored via `field.get_prep_value()` encoded with
|
|
174
|
+
`DjangoJSONEncoder` (covers `str` / `int` / `bool`, `Decimal`, `date` /
|
|
175
|
+
`datetime` / `time` / `timedelta`, `UUID`, `JSONField`, …), restored via
|
|
176
|
+
`field.to_python()`.
|
|
177
|
+
|
|
178
|
+
Out of scope for v1: `FileField` / `ImageField`, `ManyToManyField`, and (as for
|
|
179
|
+
any tracked field) the primary key, non-editable, and `auto_now` /
|
|
180
|
+
`auto_now_add` fields.
|
|
181
|
+
|
|
182
|
+
## Screenshots
|
|
183
|
+
|
|
184
|
+
<details>
|
|
185
|
+
<summary>ApprovalConfig: pick tracked fields per model</summary>
|
|
186
|
+
|
|
187
|
+

|
|
188
|
+

|
|
189
|
+
|
|
190
|
+
</details>
|
|
191
|
+
|
|
192
|
+
<details>
|
|
193
|
+
<summary>Locked field and pending-approval block on the change form</summary>
|
|
194
|
+
|
|
195
|
+

|
|
196
|
+
|
|
197
|
+
</details>
|
|
198
|
+
|
|
199
|
+
<details>
|
|
200
|
+
<summary>Reviewer: admin-index banner + ChangeRequestField changelist</summary>
|
|
201
|
+
|
|
202
|
+

|
|
203
|
+

|
|
204
|
+
|
|
205
|
+
</details>
|
|
206
|
+
|
|
207
|
+
## Development
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
poetry install
|
|
211
|
+
poetry run pytest
|
|
212
|
+
poetry run ruff check .
|
|
213
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Any
|
|
2
|
+
|
|
3
|
+
from django_approve.registry import register
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from django_approve.admin import ApprovalAdminMixin
|
|
7
|
+
|
|
8
|
+
__all__ = ["ApprovalAdminMixin", "register"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def __getattr__(name: str) -> Any:
|
|
12
|
+
"""Lazily expose ApprovalAdminMixin from the package root."""
|
|
13
|
+
if name == "ApprovalAdminMixin":
|
|
14
|
+
from django_approve.admin import ApprovalAdminMixin # noqa: PLC0415
|
|
15
|
+
|
|
16
|
+
return ApprovalAdminMixin
|
|
17
|
+
|
|
18
|
+
msg = f"module {__name__!r} has no attribute {name!r}"
|
|
19
|
+
raise AttributeError(msg)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from django_approve.admin.approval_config import ApprovalConfigAdmin
|
|
2
|
+
from django_approve.admin.change_request import ChangeRequestFieldAdmin
|
|
3
|
+
from django_approve.admin.mixins import ApprovalAdminMixin
|
|
4
|
+
|
|
5
|
+
__all__ = ["ApprovalAdminMixin", "ApprovalConfigAdmin", "ChangeRequestFieldAdmin"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
|
|
3
|
+
from django_approve.admin.forms import ApprovalConfigForm
|
|
4
|
+
from django_approve.models import ApprovalConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@admin.register(ApprovalConfig)
|
|
8
|
+
class ApprovalConfigAdmin(admin.ModelAdmin):
|
|
9
|
+
form = ApprovalConfigForm
|
|
10
|
+
list_display = ("content_type", "is_enabled", "tracked_fields")
|
|
11
|
+
list_filter = ("is_enabled",)
|
|
12
|
+
list_select_related = ("content_type",)
|
|
13
|
+
readonly_fields = ("content_type",)
|
|
14
|
+
|
|
15
|
+
def has_add_permission(self, request) -> bool:
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
def has_delete_permission(self, request, obj=None) -> bool:
|
|
19
|
+
return False
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
|
|
3
|
+
from django.contrib import admin, messages
|
|
4
|
+
from django.contrib.auth.models import AbstractBaseUser
|
|
5
|
+
from django.db.models import QuerySet
|
|
6
|
+
from django.http import HttpRequest
|
|
7
|
+
from django.utils import timezone
|
|
8
|
+
|
|
9
|
+
from django_approve.admin.filters import TargetModelFilter
|
|
10
|
+
from django_approve.config import conf
|
|
11
|
+
from django_approve.cons import ApprovalStatusChoices
|
|
12
|
+
from django_approve.exceptions import ConflictError, SelfApprovalError
|
|
13
|
+
from django_approve.models.change_request import ChangeRequestField
|
|
14
|
+
from django_approve.services import apply_field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@admin.register(ChangeRequestField)
|
|
18
|
+
class ChangeRequestFieldAdmin(admin.ModelAdmin):
|
|
19
|
+
list_display = (
|
|
20
|
+
"content_type__model",
|
|
21
|
+
"target",
|
|
22
|
+
"change_type",
|
|
23
|
+
"status",
|
|
24
|
+
"field_name",
|
|
25
|
+
"old_value",
|
|
26
|
+
"new_value",
|
|
27
|
+
"requested_by",
|
|
28
|
+
"approved_by",
|
|
29
|
+
)
|
|
30
|
+
list_filter = ("status", "change_type", TargetModelFilter)
|
|
31
|
+
readonly_fields = (
|
|
32
|
+
"content_type",
|
|
33
|
+
"object_id",
|
|
34
|
+
"target",
|
|
35
|
+
"field_name",
|
|
36
|
+
"change_type",
|
|
37
|
+
"old_value",
|
|
38
|
+
"new_value",
|
|
39
|
+
"requested_by",
|
|
40
|
+
"approved_by",
|
|
41
|
+
)
|
|
42
|
+
actions = ("approve", "reject")
|
|
43
|
+
list_select_related = ("content_type", "requested_by", "approved_by")
|
|
44
|
+
|
|
45
|
+
def get_queryset(self, request: HttpRequest) -> QuerySet[ChangeRequestField]:
|
|
46
|
+
return super().get_queryset(request).prefetch_related("target")
|
|
47
|
+
|
|
48
|
+
def has_add_permission(self, request) -> bool:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def has_delete_permission(self, request, obj=None) -> bool:
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _allowed_statuses(obj: ChangeRequestField, user: AbstractBaseUser) -> list[tuple[str, str]]:
|
|
56
|
+
"""Status choices selectable in the form for the given user.
|
|
57
|
+
|
|
58
|
+
A terminal request is locked to its current status. For a `pending`
|
|
59
|
+
request: `rejected` is the reviewer's verb (hidden from the author,
|
|
60
|
+
whose withdrawal verb is `cancelled`); `approved` is also hidden from
|
|
61
|
+
the author while four-eyes is on; `cancelled` is hidden from reviewers.
|
|
62
|
+
`deleted` is system-only and never offered.
|
|
63
|
+
"""
|
|
64
|
+
if obj.status != ApprovalStatusChoices.PENDING:
|
|
65
|
+
return [(obj.status, ApprovalStatusChoices(obj.status).label)]
|
|
66
|
+
|
|
67
|
+
excluded = {ApprovalStatusChoices.DELETED.value}
|
|
68
|
+
is_author = obj.requested_by_id == user.pk # pyrefly: ignore [missing-attribute]
|
|
69
|
+
if is_author:
|
|
70
|
+
excluded.add(ApprovalStatusChoices.REJECTED.value)
|
|
71
|
+
if conf.REQUIRE_DIFFERENT_USER:
|
|
72
|
+
excluded.add(ApprovalStatusChoices.APPROVED.value)
|
|
73
|
+
else:
|
|
74
|
+
excluded.add(ApprovalStatusChoices.CANCELLED.value)
|
|
75
|
+
|
|
76
|
+
return [choice for choice in ApprovalStatusChoices.choices if choice[0] not in excluded]
|
|
77
|
+
|
|
78
|
+
def get_form(self, request, obj=None, change=False, **kwargs): # noqa: FBT002
|
|
79
|
+
form_class = super().get_form(request, obj, change=change, **kwargs)
|
|
80
|
+
if obj is None:
|
|
81
|
+
return form_class
|
|
82
|
+
|
|
83
|
+
allowed = self._allowed_statuses(obj, request.user)
|
|
84
|
+
|
|
85
|
+
class RestrictedStatusForm(form_class):
|
|
86
|
+
def __init__(self, *args, **inner_kwargs):
|
|
87
|
+
super().__init__(*args, **inner_kwargs)
|
|
88
|
+
self.fields["status"].choices = allowed # pyrefly: ignore [missing-attribute]
|
|
89
|
+
|
|
90
|
+
return RestrictedStatusForm
|
|
91
|
+
|
|
92
|
+
def save_model(self, request, obj, form, change) -> None:
|
|
93
|
+
if "status" not in form.changed_data:
|
|
94
|
+
super().save_model(request, obj, form, change)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
obj.approved_by = request.user
|
|
98
|
+
if obj.status == ApprovalStatusChoices.APPROVED:
|
|
99
|
+
try:
|
|
100
|
+
apply_field(change_request=obj, reviewer=request.user) # pyrefly: ignore [bad-argument-type]
|
|
101
|
+
except (ConflictError, SelfApprovalError) as exc:
|
|
102
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
103
|
+
return
|
|
104
|
+
super().save_model(request, obj, form, change)
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _apply_one(change_request: ChangeRequestField, reviewer: AbstractBaseUser) -> str:
|
|
108
|
+
try:
|
|
109
|
+
apply_field(change_request=change_request, reviewer=reviewer)
|
|
110
|
+
except ConflictError:
|
|
111
|
+
return "conflict"
|
|
112
|
+
except SelfApprovalError:
|
|
113
|
+
return "blocked"
|
|
114
|
+
|
|
115
|
+
change_request.status = ApprovalStatusChoices.APPROVED
|
|
116
|
+
change_request.approved_by = reviewer # pyrefly: ignore [bad-assignment]
|
|
117
|
+
change_request.save(update_fields=["status", "approved_by", "updated"])
|
|
118
|
+
return "applied"
|
|
119
|
+
|
|
120
|
+
@admin.action(description="Approve selected change requests")
|
|
121
|
+
def approve(self, request: HttpRequest, queryset: QuerySet[ChangeRequestField]) -> None:
|
|
122
|
+
outcomes = Counter(
|
|
123
|
+
self._apply_one(change_request, request.user) # pyrefly: ignore [bad-argument-type]
|
|
124
|
+
for change_request in queryset.filter(status=ApprovalStatusChoices.PENDING)
|
|
125
|
+
)
|
|
126
|
+
msg = f"Approved & applied: {outcomes['applied']}"
|
|
127
|
+
if outcomes["conflict"]:
|
|
128
|
+
msg += f"; skipped (conflict): {outcomes['conflict']}"
|
|
129
|
+
if outcomes["blocked"]:
|
|
130
|
+
msg += f"; skipped (self-approval): {outcomes['blocked']}"
|
|
131
|
+
self.message_user(request, msg)
|
|
132
|
+
|
|
133
|
+
@admin.action(description="Reject selected change requests")
|
|
134
|
+
def reject(self, request: HttpRequest, queryset: QuerySet[ChangeRequestField]) -> None:
|
|
135
|
+
updated = queryset.filter(status=ApprovalStatusChoices.PENDING).update(
|
|
136
|
+
status=ApprovalStatusChoices.REJECTED,
|
|
137
|
+
approved_by=request.user,
|
|
138
|
+
updated=timezone.now(),
|
|
139
|
+
)
|
|
140
|
+
self.message_user(request, f"Rejected: {updated}")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from django.contrib.contenttypes.models import ContentType
|
|
3
|
+
|
|
4
|
+
from django_approve.models import ChangeRequestField
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TargetModelFilter(admin.SimpleListFilter):
|
|
8
|
+
title = "target model"
|
|
9
|
+
parameter_name = "target_model"
|
|
10
|
+
|
|
11
|
+
# pyrefly: ignore [bad-override]
|
|
12
|
+
def lookups(self, request, model_admin):
|
|
13
|
+
ct_ids = ChangeRequestField.objects.values_list("content_type", flat=True).distinct()
|
|
14
|
+
return [(ct.pk, ct.name) for ct in ContentType.objects.filter(pk__in=ct_ids)]
|
|
15
|
+
|
|
16
|
+
def queryset(self, request, queryset):
|
|
17
|
+
value = self.value()
|
|
18
|
+
return queryset.filter(content_type_id=value) if value else queryset
|