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

## 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.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` (not supported).

`ManyToManyField`s are eligible too, as long as they use Django's
auto-created through table — a custom `through=` model isn't supported and
is excluded, same as files.

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.

## 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 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).
- Snapshots exclude `FileField` / `ImageField`. A model with a **required**
  field of those types is rejected at submit time rather than filing an
  unapprovable request.
- `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.

## Supported field types

Any concrete, editable field is supported, with three 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.
- **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: `FileField` / `ImageField`; `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>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 .
```

