Skip to main content

Django reusable app for revision-based undo functionality with django-simple-history

Project description

django-undo-revision

Atomic undo / rollback for Django models — a reusable Django app that adds a "Ctrl+Z" undo button to your application data. Uses django-simple-history as its change-tracking engine.

pip install django-undo-revision

What problem does it solve?

django-simple-history tracks every change to your models, but it doesn't give you a way to group multiple changes together and roll them all back at once. django-undo-revision adds that layer:

  • A user edits a document, renames a tag, and reorders items — all in one request. One undo_last_revision() call rolls back all three changes atomically.
  • You're building a collaborative editor, a project management tool, or any app where users need a reliable undo button.
  • You want per-scope undo history (per user, per project, per session) rather than a single global history.

How it works

Every mutation is wrapped in a revision — a named unit of work. A revision automatically captures all django-simple-history records (creates, updates, deletes) made within its context and groups them under a single Revision object. Calling undo_last_revision(scope) fetches the latest revision for a scope and replays all changes in reverse order inside a transaction.

open_revision(scope_id=...)
    └── saves Document        → Version(revision=R, object=doc_history_record)
    └── deletes Tag           → Version(revision=R, object=tag_history_record)
    └── updates Item.order    → Version(revision=R, object=item_history_record)

undo_last_revision(scope)
    └── restores Item.order   (reverse chronological)
    └── restores Tag
    └── restores Document
    └── deletes Revision R

Requirements

Dependency Version
Python ≥ 3.11
Django ≥ 4.2 (tested on 4.2, 5.0, 5.1)
django-simple-history ≥ 3.11.0

Installation

pip install django-undo-revision

Add to INSTALLED_APPS (order matters — contenttypes and simple_history must come first):

INSTALLED_APPS = [
    ...
    "django.contrib.contenttypes",
    "simple_history",
    "undo_revision",
]

Run migrations:

python manage.py migrate

Configuration

In settings.py, set the scope model — the entity that revisions are grouped under. This is typically your top-level container: a project, workspace, document, user, or session.

# Required: the model that owns revisions
UNDO_REVISION_SCOPE_MODEL = "myapp.Project"

# URL kwarg used by UndoRevisionMixin to extract the scope id from the request
UNDO_REVISION_SCOPE_URL_KWARG = "project_id"

# HTTP methods that should automatically open a revision (default: all mutating methods)
UNDO_REVISION_HTTP_METHODS = ["post", "put", "patch", "delete"]

Usage

1. Inherit models from HistoricalModel

from django.db import models
from undo_revision.models import HistoricalModel


class Document(HistoricalModel):
    title = models.CharField(max_length=255)
    body = models.TextField()


class Tag(HistoricalModel):
    name = models.CharField(max_length=100)
    document = models.ForeignKey(Document, on_delete=models.CASCADE)

HistoricalModel wires up RevisionHistoricalRecords (an extended HistoricalRecords) and RevisionQuerySet as the default manager.

2. Wrap mutations in a revision

from undo_revision.revision.context import open_revision

with open_revision(scope_id=project.id):
    document.title = "New title"
    document.save()

    tag.delete()

    Item.objects.bulk_update_with_history(items, fields=["order"])

# All changes are grouped into one revision.
# If nothing was saved inside the block, the revision is deleted automatically.

You can also attach to an existing revision by id (useful when a single logical action spans multiple functions):

with open_revision(revision_id=existing_revision.id):
    ...

3. Undo the last revision

from undo_revision.revision.undo import undo_last_revision, RevisionNotFoundError

try:
    undo_last_revision(scope=project)
except RevisionNotFoundError:
    # No revisions left — nothing to undo
    return Response({"detail": "Nothing to undo."}, status=400)

undo_last_revision runs inside a transaction. It rolls back all changes in reverse chronological order, then deletes the revision record.

4. Expose an undo endpoint (DRF example)

from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from undo_revision.revision.undo import undo_last_revision, RevisionNotFoundError


class ProjectViewSet(GenericViewSet):
    @action(detail=True, methods=["post"], url_path="undo")
    def undo(self, request, pk=None):
        project = self.get_object()
        try:
            undo_last_revision(scope=project)
        except RevisionNotFoundError:
            return Response({"detail": "Nothing to undo."}, status=400)
        return Response(status=204)

5. Auto-open revisions via mixin (DRF / CBV)

UndoRevisionMixin automatically wraps all mutating methods with open_revision, so you don't have to add the context manager to every view.

from undo_revision.revision.mixins import UndoRevisionMixin
from rest_framework.viewsets import ModelViewSet


class DocumentViewSet(UndoRevisionMixin, ModelViewSet):
    queryset = Document.objects.all()
    serializer_class = DocumentSerializer
    scope_url_kwarg = "project_id"  # URL kwarg carrying the scope id

Every POST, PUT, PATCH, and DELETE request to this viewset will automatically open a revision scoped to project_id.

6. QuerySet update()

QuerySet.update() bypasses Django's post_save signal, so HistoricalModel overrides it to capture history automatically:

# History is saved automatically — no extra call needed
Document.objects.filter(project=project).update(title="New title")

# Skip history tracking explicitly
Document.objects.filter(project=project).update_without_history(title="New title")

7. Bulk operations

RevisionQuerySet exposes helpers for bulk mutations that integrate with revision tracking:

# Tracked — changes will be included in the current revision
MyModel.objects.bulk_create_with_history(objs)
MyModel.objects.bulk_update_with_history(objs, fields=["title", "order"])

# Untracked — changes are invisible to revision/undo
MyModel.objects.bulk_create_without_history(objs)
MyModel.objects.bulk_update_without_history(objs, fields=["title"])
MyModel.objects.create_without_history(title="...")
MyModel.objects.filter(...).delete_without_history()

Use the _without_history variants for seed data, migrations, or internal bookkeeping that shouldn't be undoable.

8. Cleanup all revisions

from undo_revision.revision.cleanup import cleanup_revisions

cleanup_revisions()

The cleanup removes:

  • all records from undo_revision.models.Revision
  • all records from undo_revision.models.Version
  • all records from all subclasses of undo_revision.models.HistoricalModel

Data model

Revision
  id          UUID  (PK)
  scope       FK → your scope model
  created_at  DateTimeField

Version
  id            UUID  (PK)
  revision      FK → Revision
  content_type  FK → ContentType
  object_id     TextField
  content_object GenericForeignKey → historical record (django-simple-history)

Each Version points to a django-simple-history historical record snapshot. On undo, the library reads back the pre-change field values from the snapshot and restores them.

Key features

Feature Description
Atomic multi-model rollback Group changes across many models into one revision and revert them all at once
Scoped undo history Each scope (project, user, session) has its own independent undo stack
QuerySet update() tracking filter(...).update(...) saves history automatically, no extra call needed
Bulk operation tracking bulk_create and bulk_update are revision-aware out of the box
Zero-change revision cleanup Revisions with no recorded changes are deleted automatically
DRF / CBV integration Drop-in mixin auto-opens a revision per request

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_undo_revision-3.1.0.tar.gz (30.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_undo_revision-3.1.0-py3-none-any.whl (12.6 kB view details)

Uploaded Python 3

File details

Details for the file django_undo_revision-3.1.0.tar.gz.

File metadata

  • Download URL: django_undo_revision-3.1.0.tar.gz
  • Upload date:
  • Size: 30.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for django_undo_revision-3.1.0.tar.gz
Algorithm Hash digest
SHA256 eee32c0bb26acf3590b3dd725894974ef6595f45daa301b600b8180478231ea7
MD5 2419324f6cf39944104e6e8b1ff1bc3b
BLAKE2b-256 3032e61e6f08a3e935b7e1f9bf0b9b16fd0073add43e855e0f5a37eb0ddc8c5e

See more details on using hashes here.

File details

Details for the file django_undo_revision-3.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_undo_revision-3.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f0d42e765a239824a9a2e8b13630ed29c7f6ad49aee0a4eb36e81bcf3355e2aa
MD5 a03c502544cafc5b577ec8a9fc0a0c2c
BLAKE2b-256 31901b489e61890d98d72f4c8c52eae920f9205219174b83e5f1d1d07e547708

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page