< / >

This is a blog by about coding and web development.

Undelete in Django

Posted on in

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.