Metadata-Version: 2.4
Name: django-approve-flow
Version: 0.9.2
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.
- **Inline rows can't be filed together with the object.** Nothing is written at
  submit time, so the rows would have no parent to attach to. Filling an inline
  on the add form fails validation and asks you to submit the object first and
  add the rows once it is approved — nothing typed is lost, and no request is
  filed. An inline with `validate_min` therefore makes the model uncreatable
  through the admin while track create is on.

Inline rows on the **change** form are a different matter: they are written
straight to the database and approval does not cover them at all, whatever the
child model's own tracked fields say.

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

