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
delete() is called on an object, if
None, set it to the current time but don’t delete it. If it’s not
None, actually delete it from the database.
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.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)
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() >>>
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.