django-approve-flow
Moderate edits, creation and deletion in the Django admin — a change to a tracked model field, or the creation/deletion of a tracked model's object, isn't applied directly, it waits for a second person's approval (four-eyes / maker-checker). Each is opt-in per model.
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.
Granularity is per field: one save touching three tracked fields files three
independent requests, each approved on its own. Creating and deleting whole
objects can be gated too — per model, via track_create / track_delete — see
Create approval and Delete approval, or
Screenshots for how it looks 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:
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles" # required for `collectstatic`
Run collectstatic on deploy to serve the stylesheet.
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(not supported).
ManyToManyFields are eligible too, as long as they use Django's
auto-created through table — a custom through= model isn't supported and
is excluded, same as files.
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); the in-memory value is reverted before saving. Untracked fields save normally. - The field is locked (
get_readonly_fields) and the change form shows a "Pending approval" block above it. - A reviewer sees a banner on the admin index and works through the
ChangeRequestFieldchangelist — Approve / Reject per field, or in bulk via Approve selected / Reject selected. - While any change is pending, the object's Delete is hidden and admin deletion is blocked until the requests are resolved.
[!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_AUTO_CREATE_GROUP only manages the group's permissions on migrate;
it never adds or removes users.
Create and delete approval are not global settings — each is enabled per
model via track_create / track_delete on that model's ApprovalConfig.
is_enabled is the per-model master switch: off stops field, create, and delete
approval at once.
Create approval
When track create is on, submitting the admin add form does not write the
object — it files a single pending create request snapshotting all fields, and
the object is written only on approval. Independent of tracked_fields: a model
can gate creation with an empty tracked-fields list.
Create-approval limitations
- Admin only — calling
.save()/Model.objects.create()from code bypasses it (same caveat as field updates). - Snapshots exclude
FileField/ImageField. A model with a required field of those types is rejected at submit time rather than filing an unapprovable request. ManyToManyFields are captured from the add form (the object has no pk yet to read them from) and applied with.set()after the object is saved on approval; a related object deleted before approval fails withConflictError, same as a missingForeignKeytarget.- Pending creates are deduplicated by identical payload across all users
(
(content_type, payload_hash)partial-unique lock); different objects are independent requests.
Delete approval
When track delete is on, deleting that model through the admin does not
remove the object — it files a single pending delete request snapshotting the
object into payload, and the object is removed only on approval. Both the
single-object delete and the bulk Delete selected action are diverted; the
bulk action still shows Django's confirmation page first. Independent of
tracked_fields and of create approval.
While the request is pending, the change form is frozen — all fields read-only, Save / Delete hidden, with a banner noting the object awaits deletion approval.
Delete-approval limitations
- Admin only — calling
.delete()from code (or a cascade from another object's deletion) bypasses it. - The whole object is frozen; field edits can't be submitted alongside a pending delete.
- Cascade dependencies aren't snapshotted. Django's confirmation page lists them, and the real cascade runs on approval.
- A second delete of the same object hits the per-object pending lock and isn't filed twice.
Signals
The package emits four Django signals over the request lifecycle so you can hook in your own side effects (notify reviewers, audit externally, …):
| Signal | Fired when |
|---|---|
request_created |
A pending request is filed — a diverted field edit, create, or delete. |
request_approved |
A request is approved and applied to the target. |
request_rejected |
A reviewer rejects a pending request. |
request_cancelled |
The author withdraws their own pending request. |
Each signal is sent with sender=ChangeRequestField and a change_request
keyword argument holding the affected ChangeRequestField instance. Inspect
change_request.change_type to distinguish create / update / delete.
Delivery is tied to the transaction. Signals fire via
transaction.on_commit, so receivers run only after the surrounding admin
transaction commits, outside the atomic block — if approval rolls back (e.g. a
ConflictError), nothing is emitted.
from django.dispatch import receiver
from django_approve.signals import request_approved, request_created, request_rejected
@receiver(request_created)
def notify_reviewers(sender, change_request, **kwargs):
# change_request.change_type is one of "create" / "update" / "delete";
# the row is committed by now, so hand its pk to a Celery task.
send_review_email.delay(change_request.pk)
@receiver(request_approved)
def on_approved(sender, change_request, **kwargs):
...
@receiver(request_rejected)
def on_rejected(sender, change_request, **kwargs):
...
Connect receivers from your app's AppConfig.ready() (or any module imported at
startup) so they are registered before the admin runs.
Supported field types
Any concrete, editable field is supported, with three 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. ManyToManyField— stored as a sorted list of related pks, restored viarelated_model._base_manager.filter(pk__in=...)and applied wholesale with.set()(a full replace, not an add/remove diff); raisesConflictErrorif any pk no longer resolves. The set is checked againstold_valuefor conflicts, but that guard is best-effort: the approval-time lock is taken on the target row, not the m2m through-table, so a concurrent relation write that bypasses the admin can be overwritten rather than flagged.- Everything else — stored via
field.get_prep_value()encoded withDjangoJSONEncoder(coversstr/int/bool,Decimal,date/datetime/time/timedelta,UUID,JSONField, …), restored viafield.to_python().
Not supported: FileField / ImageField; ManyToManyFields with a custom
(non-auto-created) through= model — auto-created through tables are supported,
see above; 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
Update approval: FK/M2M fields shown as resolved labels, not raw pks
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.6.1.tar.gz.
File metadata
- Download URL: django_approve_flow-0.6.1.tar.gz
- Upload date:
- Size: 30.4 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 |
d5ff173f1c6aee96be8bdbca2763e0b1d9b1b6c4a857056a52a0d69a660a5d15
|
|
| MD5 |
53a1c24fc517a7181bf344f1a9e675de
|
|
| BLAKE2b-256 |
36d85633ee080bce5fc84f2257e4f0617607968840709548f8bdc809f5f5ff18
|
File details
Details for the file django_approve_flow-0.6.1-py3-none-any.whl.
File metadata
- Download URL: django_approve_flow-0.6.1-py3-none-any.whl
- Upload date:
- Size: 36.3 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 |
edf9bcd7a0323be905745afb356e794a57187313a12e1fb3a479ffda71556e39
|
|
| MD5 |
e135b0e1a88afc3a462ce127f58228ee
|
|
| BLAKE2b-256 |
6c8a8db257b787a2adb42f7dd48dd6a1596e08ba717c685382d38e5b9c3e84cc
|