django-approve-flow
Moderate edits in the Django admin — a change to a tracked model field isn't saved directly, it waits for a second person's approval (four-eyes / maker-checker). Object creation and deletion can be gated the same way (opt-in per setting).
Granularity is per field, not per object. A single save touching three tracked fields creates three independent requests, each with its own status and its own reviewer. There is no batch / "change set" model — grouping is purely a UX artifact (one "Submitted for approval: a, b, c" message).
How it works
- Register a model to make its fields eligible for approval.
- Pick which eligible fields are actually tracked, in the admin.
- Add the admin mixin. Editing a tracked field now creates an approval request instead of writing the value.
- A reviewer approves or rejects each request — per field, independently.
Beyond field edits, you can optionally gate creating and deleting whole objects behind the same approval flow (same tracked-field gate) — see Create approval and Delete approval.
See Screenshots for what this looks like in the admin.
Installation
pip install django-approve-flow
INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.staticfiles",
"django_approve",
]
The admin ships a CSS asset, so django.contrib.staticfiles must be enabled
and static files configured. At minimum:
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles" # required for `collectstatic`
Run collectstatic when deploying so the stylesheet is served.
Run migrate. This creates the ApprovalConfig / ChangeRequestField tables,
syncs an ApprovalConfig row per registered model, and creates the Approvals
group with view / change permissions on both models.
Assigning reviewers
The package creates the Approvals group but never adds users to it —
membership is what makes someone a reviewer, and that is up to you. Add each
reviewer to the group in the admin: Users → pick user → Groups → Approvals.
Optionally, add the middleware to show reviewers an "N change request(s) awaiting review" banner on the admin index:
MIDDLEWARE = [
"django_approve.middlewares.PendingApprovalsNoticeMiddleware",
]
It only fires on GET /admin/, for active users in the Approvals group, and
only when at least one pending request exists.
Usage
1. Register a model
from django_approve.registry import register
@register
class Employee(models.Model):
name = models.CharField(max_length=255)
salary = models.DecimalField(max_digits=10, decimal_places=2)
manager = models.ForeignKey("self", null=True, on_delete=models.SET_NULL)
Bare @register makes every eligible field a candidate. A field is eligible
when it is concrete and editable, and is not:
- the primary key,
- non-editable,
- an
auto_now/auto_now_addtimestamp, - a
FileField/ImageField(files and M2M are out of scope for v1).
To narrow the set further, pass fields — it is intersected with the eligible
candidates:
@register(fields=["salary", "manager"])
class Employee(models.Model):
...
Registering only makes a field eligible — nothing is tracked yet.
2. Pick tracked fields in the admin
Each registered model gets an ApprovalConfig row (synced automatically on
migrate). In the ApprovalConfig admin, check which candidate fields should
actually go through the approval flow — this is tracked_fields, a subset of
the candidates. Rows can't be added or deleted by hand; they only come from the
sync.
3. Add the admin mixin
from django_approve import ApprovalAdminMixin
@admin.register(Employee)
class EmployeeAdmin(ApprovalAdminMixin, admin.ModelAdmin):
...
From here on, editing a tracked field through this admin no longer writes it directly:
- The change is diverted into a
ChangeRequestField(status=pending)with the old / new value serialized, and the in-memory value is reverted before saving. Untracked fields save normally in the same request. - While a request is pending, the field is locked (
get_readonly_fields) and the change form shows a "Pending approval" block above it. - A reviewer (member of the
Approvalsgroup) sees a banner on the admin index, then works through pending rows in theChangeRequestFieldchangelist — Approve or Reject, per field, independently. Both are also available as bulk actions: select multiple pending rows and run Approve selected / Reject selected in one go. - While any field change is pending, the object's Delete button is hidden and admin deletion is blocked — the pending requests must be approved or rejected first.
[!WARNING] Locking only happens in the admin. The whole flow — diverting edits, locking fields, showing the pending block — lives in
ApprovalAdminMixin. Calling.save()from code (management commands, Celery tasks, shell, DRF) bypasses it entirely and writes straight to the row. For the same guarantee outside the admin, callapply_fieldyourself or add your own guard — there is no model-level enforcement.
Statuses
| Status | Meaning |
|---|---|
pending |
Awaiting review. Field is locked. |
approved |
Applied to the target in the same atomic transaction as the status change. There is no separate "applied" state. |
rejected |
Reviewer declined the change. Reviewer-only verb. |
cancelled |
The author withdrew the request. Author-only verb. |
deleted |
The target was deleted while the request was pending. Set automatically via post_delete; never a manual choice. |
A pending request can only move forward, and the role restricts the available choices:
- the author can
cancel, but neverapprove/rejecttheir own request (whenAPPROVE_REQUIRE_DIFFERENT_USERis on); - a reviewer can
approve/reject, but notcancelsomeone else's request.
If the target's current value no longer matches the recorded old_value at
approval time (someone else changed it in the meantime), approval fails with a
ConflictError shown as an admin message — the request stays pending and
nothing is applied.
Settings
All settings are optional; defaults are shown.
APPROVE_AUTO_CREATE_GROUP = True # create/maintain the Approvals group via post_migrate
APPROVE_GROUP_NAME = "Approvals" # group name; membership = reviewer
APPROVE_REQUIRE_DIFFERENT_USER = True # four-eyes: block self-approval (SelfApprovalError)
APPROVE_REQUIRE_CREATE_APPROVAL = False # gate object creation behind approval (see below)
APPROVE_REQUIRE_DELETE_APPROVAL = False # gate object deletion behind approval (see below)
APPROVE_AUTO_CREATE_GROUP only controls whether the package manages the
group's permissions on migrate; it never adds or removes users.
When APPROVE_REQUIRE_CREATE_APPROVAL is on, submitting the admin add form
for a model that has at least one tracked field does not write the object; it
creates a single pending create request snapshotting all fields. The object is
written only when a reviewer approves. A model with no tracked fields is created
normally — create approval follows the same tracked_fields gate as updates.
Create-approval limitations (v1)
- Diversion happens only in the admin. Calling
.save()/Model.objects.create()from code bypasses create approval (same caveat as field updates). - Create snapshots exclude
FileField/ImageField/ManyToManyField. A model with a required field of those types is not supported by create approval in v1 — the add form is rejected at submit time with a validation error instead of filing an unapprovable request. - Pending create requests are deduplicated by identical payload across all
users (the
(content_type, payload_hash)partial-unique lock); two genuinely different new objects are independent requests.
Delete approval
When APPROVE_REQUIRE_DELETE_APPROVAL is on, deleting a tracked model through
the admin does not remove the object; it creates a single pending delete request
that snapshots the object's fields into payload. Both the single-object delete
and the bulk Delete selected action are diverted — on the bulk action
Django's standard confirmation page is shown first, and the request is filed only
after confirmation. The object is removed only when a reviewer approves; the
snapshot is shown to the reviewer so they can see what will be deleted. Delete
approval follows the same gate as create/update: the model must be registered
and have at least one tracked field.
While a delete request is pending, the target's admin change-form is frozen — all fields become read-only and the Save / Delete buttons are hidden, with a banner noting the object is awaiting deletion approval.
Delete-approval limitations (v1)
- Diversion happens only in the admin. Calling
.delete()from code (or a cascade from another object's deletion) bypasses delete approval. - The whole object is frozen while pending; individual field edits cannot be submitted alongside a pending delete.
- Cascade dependencies are not snapshotted. Django's confirmation page lists them as usual, and the real cascade runs when the delete is approved.
- A second delete of the same object hits the per-object pending lock and is not filed twice.
Supported field types (v1)
Any concrete, editable field is supported, with two serialization paths:
- Relations (
ForeignKey,OneToOneField) — stored as the related object's.pk, restored viarelated_model._base_manager.get(pk=...); raisesConflictErrorinstead ofDoesNotExistif the target was deleted before approval. - Everything else — stored via
field.get_prep_value()encoded withDjangoJSONEncoder(coversstr/int/bool,Decimal,date/datetime/time/timedelta,UUID,JSONField, …), restored viafield.to_python().
Out of scope for v1: FileField / ImageField, ManyToManyField, and (as for
any tracked field) the primary key, non-editable, and auto_now /
auto_now_add fields.
Screenshots
ApprovalConfig: pick tracked fields per model
Locked field and pending-approval block on the change form
Reviewer: admin-index banner + ChangeRequestField changelist
Create approval: reviewing a pending new object
Update approval: reviewing a pending field change
Delete approval: frozen object awaiting deletion
Delete approval: reviewing a pending delete request
Development
poetry install
poetry run pytest
poetry run ruff check .
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_approve_flow-0.3.1.tar.gz.
File metadata
- Download URL: django_approve_flow-0.3.1.tar.gz
- Upload date:
- Size: 26.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
poetry/2.2.1 CPython/3.11.9 Darwin/24.6.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f99420ab6c76444581ef6a01a251e2adb5c373669751fd5679b1818d2b80dde2
|
|
| MD5 |
2e945184b930277d40ef95b3fa8e8740
|
|
| BLAKE2b-256 |
75cafd6a9d12ed559292a09753e45c544134205a09dab78c4e94fe6c4306f196
|
File details
Details for the file django_approve_flow-0.3.1-py3-none-any.whl.
File metadata
- Download URL: django_approve_flow-0.3.1-py3-none-any.whl
- Upload date:
- Size: 32.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
poetry/2.2.1 CPython/3.11.9 Darwin/24.6.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aaa9c6425d26f2484bca38b812dccaae54089135df5321b2672a7a941ab24bab
|
|
| MD5 |
dbdc73164eb6a4162439921cdcaaa3fe
|
|
| BLAKE2b-256 |
684a268796c0b0098cc3293ede34fc66397ec3eb6a991d70e6505a4429db1a45
|