Metadata-Version: 2.4
Name: django-approve-flow
Version: 0.9.1
Summary: Moderate edits, creation and deletion in the Django admin: tracked changes wait for a second person's approval (four-eyes / maker-checker)
License-Expression: MIT
License-File: LICENSE
Keywords: django,admin,approval,maker-checker,four-eyes,workflow,moderator
Author: Denis Novikov
Author-email: alpden550@gmail.com
Requires-Python: >=3.13
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Dist: django (>=5.2)
Project-URL: Homepage, https://github.com/alpden550/django-approve
Project-URL: Issues, https://github.com/alpden550/django-approve/issues
Project-URL: Repository, https://github.com/alpden550/django-approve
Description-Content-Type: text/markdown

# 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](https://github.com/alpden550/django-approve/actions/workflows/ci.yml/badge.svg)](https://github.com/alpden550/django-approve/actions/workflows/ci.yml)
[![PyPI version](https://img.shields.io/pypi/v/django-approve-flow.svg)](https://pypi.org/project/django-approve-flow/)
[![Python versions](https://img.shields.io/pypi/pyversions/django-approve-flow.svg)](https://pypi.org/project/django-approve-flow/)
[![Django](https://img.shields.io/badge/django-5.2%2B-092e20.svg)](https://www.djangoproject.com/)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)

![Seven tracked fields waiting for approval on a change form, each shown as a current → requested diff, with the fields themselves locked below](docs/screenshots/pending_approval.png)

## 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](#create-approval) and [Delete approval](#delete-approval), or
[Screenshots](#screenshots) for how it looks in the admin.

## Installation

```bash
pip install django-approve-flow
```

```python
INSTALLED_APPS = [
    "django.contrib.contenttypes",
    "django.contrib.staticfiles",
    "django_approve",
]
```

The admin ships a CSS asset, so `django.contrib.staticfiles` must be enabled:

```python
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:

```python
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

```python
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](#supported-field-types)). `ManyToManyField`s
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:

```python
@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

```python
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:

```python
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](docs/screenshots/dashboard.png)

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

```python
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.
- `ManyToManyField`s 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.

```python
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:

```python
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`](https://github.com/un1t/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: `ManyToManyField`s 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

<details>
<summary>ApprovalConfig: pick tracked fields per model</summary>

![Approval configurations changelist](docs/screenshots/configurations.png)
![Picking tracked fields for a model](docs/screenshots/tracked_fields.png)

</details>

<details>
<summary>Locked field and pending-approval block on the change form</summary>

![Locked fields with a pending-approval block](docs/screenshots/model.png)

</details>

<details>
<summary>Reviewer: admin-index banner + ChangeRequestField changelist</summary>

![Pending-requests banner on the admin index](docs/screenshots/approvers.png)
![Change request fields changelist](docs/screenshots/requests.png)

</details>

<details>
<summary>Create approval: reviewing a pending new object</summary>

![Pending create request showing the requested object snapshot](docs/screenshots/created.png)

</details>

<details>
<summary>Update approval: reviewing a pending field change</summary>

![Pending field-update request shown as a current → requested diff card](docs/screenshots/change_request.png)

</details>

<details>
<summary>Update approval: FK/M2M fields shown as resolved labels, not raw pks</summary>

![Target change form banner showing demo_fk and demo_m2m diffs resolved to related-object labels](docs/screenshots/pending_fk_m2m.png)
![Reviewer diff card for a ManyToMany field resolved to related-object labels](docs/screenshots/approve_m2m.png)

</details>

<details>
<summary>File / Image fields: create and update store the storage name</summary>

![Pending create request snapshotting an ImageField as its storage name](docs/screenshots/create_img.png)
![Pending update to an ImageField shown as a current → requested storage-name diff](docs/screenshots/update_img.png)

</details>

<details>
<summary>Delete approval: frozen object awaiting deletion</summary>

![Object marked for deletion with all fields read-only and buttons hidden](docs/screenshots/deleted.png)

</details>

<details>
<summary>Delete approval: reviewing a pending delete request</summary>

![Pending delete request showing the object snapshot that will be deleted](docs/screenshots/requested_delete.png)

</details>

## Development

```bash
poetry install
poetry run pytest
poetry run ruff check .
```

