Undelete in Django
Simon Willison linked to an article which argues:
Warnings cause us to lose our work, to mistrust our computers, and to blame ourselves. A simple but foolproof design methodology solves the problem: Never use a warning when you mean undo. And when a user is deleting their work, you always mean undo.
The post spawned a discussion on undo techniques for Django. I decided to implement one method and post the results here. It only offers undo for deleting, and not for editing. Other than that, I like it.
How it works
It’s a pretty simple concept: add a trashed_at
field to your model, with the
default value of None
. When delete()
is called on an object, if trashed_at
is None
, set it to the current time but don’t delete it. If it’s not None
,
actually delete it from the database.
Model
Here’s what I did:
from datetime import datetime
from django.db import models
class SomeModel(models.Model):
# ... other fields ...
trashed_at = models.DateTimeField(blank=True, null=True)
objects = NonTrashManager()
trash = TrashManager()
def __str__(self):
trashed = (self.trashed_at and 'trashed' or 'not trashed')
return '%d (%s)' % (self.id, trashed)
def delete(self, trash=True):
if not self.trashed_at and trash:
self.trashed_at = datetime.now()
self.save()
else:
super(SomeModel, self).delete()
def restore(self, commit=True):
self.trashed_at = None
if commit:
self.save()
The custom managers are used to make it so SomeModel.objects
and
SomeModel.trash
only query against the appropriate rows:
class NonTrashManager(models.Manager):
''' Query only objects which have not been trashed. '''
def get_query_set(self):
query_set = super(NonTrashManager, self).get_query_set()
return query_set.filter(trashed_at__isnull=True)
class TrashManager(models.Manager):
''' Query only objects which have been trashed. '''
def get_query_set(self):
query_set = super(TrashManager, self).get_query_set()
return query_set.filter(trashed_at__isnull=False)
Usage
Here are some examples:
# use the managers to see what's what
>>> SomeModel.objects.count()
5L
>>> SomeModel.trash.count()
0L
# grab a non-trashed object
>>> object = SomeModel.objects.get(id=1)
>>> object
<Item: 1 (not trashed)>
# now delete it (move it to the trash)
>>> object.delete()
>>> object
<Item: 1 (trashed)>
>>> SomeModel.objects.count()
4L
>>> SomeModel.trash.count()
1L
# undo the delete
>>> object.restore()
>>> object
<Item: 1 (not trashed)>
# trash it again
>>> object.delete()
# calling delete again will *really* delete it
>>> object.delete()
# you could also force it to skip the trash
>>> object = SomeModel.objects.get(id=2)
>>> object.delete(trash=False)
# you could use a date range filter to delete
# everything trashed over a month ago
>>> from datetime import datetime
>>> from dateutil.relativedelta import relativedelta
>>> month_ago = datetime.now() - relativedelta(months=1)
>>> objects = SomeModel.trashed.filter(trashed_at__lte=month_ago)
>>> for object in objects:
... object.delete()
>>>
Code
Here’s a zip of the source code for my test. It ain’t perfect. To keep it simple, I didn’t use AJAX for the deleting and undoing, but it wouldn’t be hard to add it yourself.