Metadata-Version: 2.4
Name: django-approve-flow
Version: 0.5.0
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.0
Classifier: Framework :: Django :: 5.1
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)
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%2B-092e20.svg)](https://www.djangoproject.com/)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)

**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](#settings) and [Delete approval](#delete-approval).

See [Screenshots](#screenshots) for what this looks like 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
and static files configured. At minimum:

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

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

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

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

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.

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

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

