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