Skip to main content

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.

CI PyPI version Python versions Django License: MIT

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

  1. Register a model to make its fields eligible for approval.
  2. Pick which eligible fields are actually tracked, in the admin.
  3. Add the admin mixin. Editing a tracked field now creates an approval request instead of writing the value.
  4. 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 — enabled independently, per model, via track_create / track_delete on that model's ApprovalConfig — 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_add timestamp,
  • 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 Approvals group) sees a banner on the admin index, then works through pending rows in the ChangeRequestField changelist — 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, call apply_field yourself 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 never approve / reject their own request (when APPROVE_REQUIRE_DIFFERENT_USER is on);
  • a reviewer can approve / reject, but not cancel someone 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 controls whether the package manages the group's permissions on migrate; it never adds or removes users.

Create and delete approval are not global settings — they are enabled per model via track_create / track_delete on that model's ApprovalConfig (admin). is_enabled on the config is the per-model master switch: turning it off stops field, create, and delete approval for that model at once.

When track create is on, submitting the admin add form does not write the object; it creates a single pending create request snapshotting all fields. The object is written only when a reviewer approves. This is independent of tracked_fields — a model can gate creation with an empty tracked-fields list.

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 track delete is on (per model, on ApprovalConfig), deleting that 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 is independent of tracked_fields and of create approval — each model decides its own combination.

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.

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 a receiver only runs once the surrounding admin transaction actually commits — if approval rolls back (e.g. a ConflictError), nothing is emitted. Receivers therefore run after commit, outside the atomic block.

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"
    send_review_email(change_request)


@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 (v1)

Any concrete, editable field is supported, with two serialization paths:

  • Relations (ForeignKey, OneToOneField) — stored as the related object's .pk, restored via related_model._base_manager.get(pk=...); raises ConflictError instead of DoesNotExist if the target was deleted before approval.
  • Everything else — stored via field.get_prep_value() encoded with DjangoJSONEncoder (covers str / int / bool, Decimal, date / datetime / time / timedelta, UUID, JSONField, …), restored via field.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

Approval configurations changelist Picking tracked fields for a model

Locked field and pending-approval block on the change form

Locked fields with a pending-approval block

Reviewer: admin-index banner + ChangeRequestField changelist

Pending-requests banner on the admin index Change request fields changelist

Create approval: reviewing a pending new object

Pending create request showing the requested object snapshot

Update approval: reviewing a pending field change

Pending field-update request shown as a current → requested diff card

Delete approval: frozen object awaiting deletion

Object marked for deletion with all fields read-only and buttons hidden

Delete approval: reviewing a pending delete request

Pending delete request showing the object snapshot that will be deleted

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

django_approve_flow-0.5.1.tar.gz (28.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_approve_flow-0.5.1-py3-none-any.whl (34.6 kB view details)

Uploaded Python 3

File details

Details for the file django_approve_flow-0.5.1.tar.gz.

File metadata

  • Download URL: django_approve_flow-0.5.1.tar.gz
  • Upload date:
  • Size: 28.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.2.1 CPython/3.11.9 Darwin/24.6.0

File hashes

Hashes for django_approve_flow-0.5.1.tar.gz
Algorithm Hash digest
SHA256 007cc1e99954d728b46fe291d9d6906983e803307c0f8ed77734632803660a2b
MD5 6c25e2a3f6ee01d72edc4b1f586a79ef
BLAKE2b-256 f40bad111a93b87702b4525c55eb662c72561c02673bed33f9b90c5eeccd84f4

See more details on using hashes here.

File details

Details for the file django_approve_flow-0.5.1-py3-none-any.whl.

File metadata

File hashes

Hashes for django_approve_flow-0.5.1-py3-none-any.whl
Algorithm Hash digest
SHA256 7400157e900aacba6844dddde60acd54c42fe375b6e930ce7d3858fd28367ec5
MD5 83b5c3a125f8678f4f07a85d47801a40
BLAKE2b-256 8a62ca3b8c0a0953dc6bcf0a6b6b2ba7fe2d4d233618092e5423ece9327cdb43

See more details on using hashes here.

Release history Release notifications | RSS feed

0.10.0

2 files

0.9.2

2 files

0.9.1

2 files

0.9.0

2 files

0.8.0

2 files

0.7.0

2 files

0.6.1

2 files

0.6.0

2 files

0.5.2

2 files

This release

0.5.1 This release

2 files

0.5.0

2 files

0.4.0

2 files

0.3.1

2 files

0.2.0

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page