< / >

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.