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

Seven tracked fields waiting for approval on a change form, each shown as a current → requested diff, with the fields themselves locked below

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.

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 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.

FileField / ImageField are eligible too — an upload submitted for approval is written to the field's storage at submit time and the pending request stores only its name (see Supported field types). ManyToManyFields are eligible as long as they use Django's auto-created through table — a custom through= model isn't supported and is excluded.

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 ChangeRequestField changelist — 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, call apply_field yourself or add your own guard — there is no model-level enforcement.

4. Custom save_model / delete_model

The mixin works through save_model and delete_model. If your ModelAdmin overrides one of them without calling super(), the flow is off: the write goes straight to the database and no change request is created.

To let some users write directly (and everyone else go through approval), call super() for those who must file a request and admin.ModelAdmin for those who may bypass it:

def must_request(user) -> bool:
    return not user.groups.filter(name="Release managers").exists()


class EmployeeAdmin(ApprovalAdminMixin, admin.ModelAdmin):
    def save_model(self, request, obj, form, change):
        if must_request(request.user):
            super().save_model(request, obj, form, change)
        else:
            admin.ModelAdmin.save_model(self, request, obj, form, change)

    def delete_model(self, request, obj):
        if must_request(request.user):
            super().delete_model(request, obj)
        else:
            admin.ModelAdmin.delete_model(self, request, obj)

admin.ModelAdmin.save_model(self, ...) is called explicitly to step over the mixin and do a plain write. Note that this only skips the diversion — locked fields still come from get_readonly_fields, override it too if such a user should be able to edit a field with a pending request.

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.

Once a request leaves pending it is history: the change form is fully readonly, status included, so a decision cannot be reopened or rewritten from the admin.

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.

Reviewer decisions

  • decision_note — a reason. Required to reject (the batch Reject selected action asks for one reason for the whole selection), optional on approve. Checked in the form only, not in the DB.
  • assignee — an exclusive claim, shown as a column in the changelist. Deciding an unclaimed request claims it, in the change form and in the bulk actions alike; after that only the assignee or a superuser may decide it. Approve selected / Reject selected skip requests claimed by someone else and report how many were skipped. The Claim selected / Release selected actions set and drop the claim by hand; Release selected only drops your own claims unless you are a superuser.

Queue dashboard

A read-only overview of the pending queue, reachable from the Queue dashboard button on the change-request changelist and from the admin-index banner. Needs the same view permission as the changelist; no setting.

The queue dashboard: headline count, tiles, arrivals chart, stale requests and slice tables

  • waiting for review — the headline count, carrying the change against the previous 7 days and a bar splitting the queue into Mine, Unclaimed and everyone else. The delta carries an arrow and a sign: up and orange when the queue grows, down and green when it shrinks.
  • Mine and Unclaimed — the same slices as tiles, each with its share of the queue underneath. Overlapping views, not a partition. Oldest sits beside them with the longest wait, the day that request arrived and a link to it.
  • Arrivals per day — requests filed on each of the last 14 days, as columns; the window is dated on both ends, the peak is labelled and the rest show their number on hover. Underneath, how many requests were closed over the same window and the median time to decide (<1m, 20m, 5h, 1.4d).
  • Withdrawn — requests the requester cancelled over that window, with their share of the intake. Closed counts only approved and rejected; cancelled is reported here instead, and deleted is excluded — there the target object went away.
  • Needs attention — up to five oldest requests past the last age bucket, each linking to its own change form. My queue sits beside it with the requests locked to you.
  • By assignee / By target model / By age — slice tables; each row links to the changelist filtered to that slice. Both nominal tables fold the tail into one row, which stays plain text — it spans several slices, so no single filter fits it — and By assignee pins Unclaimed on top with a marker of its own. Age buckets do not overlap (under 1 day, 1–3, 3–7, over 7); the first three step one ramp, the last is flagged in the critical colour and says needs attention.

Text and surfaces read admin's --body-* variables; the chart colours are the package's own --dja-* custom properties and can be overridden. Status is never carried by colour alone — every flagged row also says so in words. Throughput needs an index on updated, added in migration 0006.

Queue figures cover every pending request, not just the current changelist filter — unlike the sidebar's facet counts, which are computed from the filtered queryset. Arrivals, closed and withdrawn count requests of any status inside their window. The dashboard decides nothing: claim, release, approve and reject stay in the changelist and the change form.

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).
  • FileField / ImageField uploads are written to storage at submit time and the snapshot stores the name; the file is discarded if the request is rejected or cancelled. A required file left empty still fails validation at submit time, as it would in a normal add.
  • 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 with ConflictError, same as a missing ForeignKey target.
  • 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.

Notification helpers

The package never sends anything itself; it ships the primitives and you pick the channel. reviewer_recipients() returns the active members of the reviewers group as users (not emails), and describe_change() renders a request as one human-readable line:

from django.core.mail import send_mail
from django.dispatch import receiver

from django_approve import describe_change, reviewer_recipients
from django_approve.signals import request_created


@receiver(request_created)
def notify_reviewers(sender, change_request, **kwargs):
    recipients = reviewer_recipients(exclude=change_request.requested_by)
    emails = [user.email for user in recipients if user.email]
    if emails:
        send_mail(
            subject="Change awaiting review",
            message=describe_change(change_request),
            from_email=None,
            recipient_list=emails,
        )

exclude takes a user or an iterable of users — dropping the requester is the caller's policy, not a built-in rule. For a single value, resolve_display_value (also exported from the package root) turns a stored FK/M2M pk into its label.

Supported field types

Any concrete, editable field is supported, with four 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.
  • ManyToManyField — stored as a sorted list of related pks, restored via related_model._base_manager.filter(pk__in=...) and applied wholesale with .set() (a full replace, not an add/remove diff); raises ConflictError if any pk no longer resolves. The set is checked against old_value for 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.
  • FileField / ImageField — the uploaded file is written to the field's own storage under its normal upload_to at submit time, and only the storage name is stored in the request. On approval the object simply adopts that name (no copy). The library deletes a staged upload if its request is rejected, cancelled, or orphaned by the target's deletion, but never touches the object's previous file on replace — that lifecycle is Django's default (pair it with django-cleanup if you want old files removed).
  • 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().

Not supported: 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

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

Update approval: FK/M2M fields shown as resolved labels, not raw pks

Target change form banner showing demo_fk and demo_m2m diffs resolved to related-object labels Reviewer diff card for a ManyToMany field resolved to related-object labels

File / Image fields: create and update store the storage name

Pending create request snapshotting an ImageField as its storage name Pending update to an ImageField shown as a current → requested storage-name diff

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.9.1.tar.gz (50.4 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.9.1-py3-none-any.whl (58.2 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for django_approve_flow-0.9.1.tar.gz
Algorithm Hash digest
SHA256 32a6701323aa24647d652380c4d075540ad7a5c701e1ab8133c0f8414240eb36
MD5 d1d718959771dddc1bb607e83b610cfa
BLAKE2b-256 d338ee3a868acdf219af1830efa833842a2cff41016e3bde57bc073493c894ce

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for django_approve_flow-0.9.1-py3-none-any.whl
Algorithm Hash digest
SHA256 f72280084539694ea37338c601c6264aab37579a115f79df0f121edf7686f81a
MD5 8a7f65025d2ed1f7fdd11a678bbb3c7a
BLAKE2b-256 be9a353f4ca132e03eeb94210003b6d4913e7fdb25cfd637f79558cee3a135fd

See more details on using hashes here.

Release history Release notifications | RSS feed

0.10.0

2 files

0.9.2

2 files

This release

0.9.1 This release

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

0.5.1

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