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.
Files changed (27) hide show
  1. django_approvals-0.1.0/LICENSE +21 -0
  2. django_approvals-0.1.0/PKG-INFO +241 -0
  3. django_approvals-0.1.0/README.md +213 -0
  4. django_approvals-0.1.0/django_approve/__init__.py +19 -0
  5. django_approvals-0.1.0/django_approve/admin/__init__.py +5 -0
  6. django_approvals-0.1.0/django_approve/admin/approval_config.py +19 -0
  7. django_approvals-0.1.0/django_approve/admin/change_request.py +140 -0
  8. django_approvals-0.1.0/django_approve/admin/filters.py +18 -0
  9. django_approvals-0.1.0/django_approve/admin/forms.py +60 -0
  10. django_approvals-0.1.0/django_approve/admin/mixins.py +83 -0
  11. django_approvals-0.1.0/django_approve/apps.py +20 -0
  12. django_approvals-0.1.0/django_approve/config.py +24 -0
  13. django_approvals-0.1.0/django_approve/cons.py +15 -0
  14. django_approvals-0.1.0/django_approve/exceptions.py +16 -0
  15. django_approvals-0.1.0/django_approve/fields.py +54 -0
  16. django_approvals-0.1.0/django_approve/middlewares.py +48 -0
  17. django_approvals-0.1.0/django_approve/migrations/0001_initial.py +104 -0
  18. django_approvals-0.1.0/django_approve/migrations/__init__.py +0 -0
  19. django_approvals-0.1.0/django_approve/models/__init__.py +4 -0
  20. django_approvals-0.1.0/django_approve/models/approval_config.py +16 -0
  21. django_approvals-0.1.0/django_approve/models/change_request.py +42 -0
  22. django_approvals-0.1.0/django_approve/registry.py +71 -0
  23. django_approvals-0.1.0/django_approve/serializers.py +30 -0
  24. django_approvals-0.1.0/django_approve/services.py +48 -0
  25. django_approvals-0.1.0/django_approve/signals.py +78 -0
  26. django_approvals-0.1.0/django_approve/templates/django_approve/target_change_form.html +34 -0
  27. 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
+ [![CI](https://github.com/alpden550/django-approve/actions/workflows/ci.yml/badge.svg)](https://github.com/alpden550/django-approve/actions/workflows/ci.yml)
35
+ [![Python](https://img.shields.io/badge/python-3.13%2B-blue.svg)](https://www.python.org/)
36
+ [![Django](https://img.shields.io/badge/django-5%2B-092e20.svg)](https://www.djangoproject.com/)
37
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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
+ ![Approval configurations changelist](docs/screenshots/configurations.png)
215
+ ![Picking tracked fields for a model](docs/screenshots/tracked_fields.png)
216
+
217
+ </details>
218
+
219
+ <details>
220
+ <summary>Locked field and pending-approval block on the change form</summary>
221
+
222
+ ![Locked fields with a pending-approval block](docs/screenshots/model.png)
223
+
224
+ </details>
225
+
226
+ <details>
227
+ <summary>Reviewer: admin-index banner + ChangeRequestField changelist</summary>
228
+
229
+ ![Pending-requests banner on the admin index](docs/screenshots/approvers.png)
230
+ ![Change request fields changelist](docs/screenshots/requests.png)
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
+ [![CI](https://github.com/alpden550/django-approve/actions/workflows/ci.yml/badge.svg)](https://github.com/alpden550/django-approve/actions/workflows/ci.yml)
8
+ [![Python](https://img.shields.io/badge/python-3.13%2B-blue.svg)](https://www.python.org/)
9
+ [![Django](https://img.shields.io/badge/django-5%2B-092e20.svg)](https://www.djangoproject.com/)
10
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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
+ ![Approval configurations changelist](docs/screenshots/configurations.png)
188
+ ![Picking tracked fields for a model](docs/screenshots/tracked_fields.png)
189
+
190
+ </details>
191
+
192
+ <details>
193
+ <summary>Locked field and pending-approval block on the change form</summary>
194
+
195
+ ![Locked fields with a pending-approval block](docs/screenshots/model.png)
196
+
197
+ </details>
198
+
199
+ <details>
200
+ <summary>Reviewer: admin-index banner + ChangeRequestField changelist</summary>
201
+
202
+ ![Pending-requests banner on the admin index](docs/screenshots/approvers.png)
203
+ ![Change request fields changelist](docs/screenshots/requests.png)
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