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
Metadata
Release files for django-undo-revision 3.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| django_undo_revision-3.1.0.tar.gz | 30.0 kB | Without provenance | Details for django_undo_revision-3.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| django_undo_revision-3.1.0-py3-none-any.whl | Python 3 | none | any | Details for django_undo_revision-3.1.0-py3-none-any.whl |
Total release size: 42.6 kB
Release files / django_undo_revision-3.1.0.tar.gz
| Download URL | django_undo_revision-3.1.0.tar.gz |
|---|---|
| Size | 30.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
eee32c0bb26acf3590b3dd725894974ef6595f45daa301b600b8180478231ea7
|
|
BLAKE2b-256 checksum How to use checksums |
3032e61e6f08a3e935b7e1f9bf0b9b16fd0073add43e855e0f5a37eb0ddc8c5e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Release files / django_undo_revision-3.1.0-py3-none-any.whl
| Download URL | django_undo_revision-3.1.0-py3-none-any.whl |
|---|---|
| Size | 12.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f0d42e765a239824a9a2e8b13630ed29c7f6ad49aee0a4eb36e81bcf3355e2aa
|
|
BLAKE2b-256 checksum How to use checksums |
31901b489e61890d98d72f4c8c52eae920f9205219174b83e5f1d1d07e547708
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Security for django-undo-revision 3.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.