Undelete in Django

On July 18, 2007 in development, django, python

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):
    return '%d (%s)' % (self.id, \
      (self.trashed_at and 'trashed' or 'not 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):
    return super(NonTrashManager, self) \
           .get_query_set().filter(trashed_at__isnull=True)

class TrashManager(models.Manager):
  ''' Query only objects which have been trashed. '''
  def get_query_set(self):
    return super(TrashManager, self) \
           .get_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.

47 comments Add yours…

Patrick Anderson, 9 months ago

Nathan, I implemented that in my project and it seems to work.

One place it needs some work is the admin, where trashed items are displayed in the list of objects, but if an item is trashed , you can’t edit it, as it doesn’t exists in ‘objects’.

Patrick, 9 months ago

Perhaps instead of replacing objects with ‘non-trashed’ queryset, you could create another field in your model other than ‘objects’ that would become NonTrashManager().

A little more work, but it wouldn’t ‘break ’ the admin.

Amit Upadhyay, 9 months ago

Nice work!

But this is only part of the solution, a lot actions can not be represented in terms of a model delete. Changing relations for example, removing a user from friends list. Any thoughts on that?

Patrick Anderson, 9 months ago

I ended up adding ‘trash’, ‘restore’ actions, and leaving default ‘delete’ for Django model as is to avoid problems in admin.

Need to modify my own code and templates, but it’s a lot less pain than modifying default behaviour.

SmileyChris, 9 months ago

Couple of suggestions:

1. I’d combine the two managers into one (and call it TrashManager)

`get_query_set` should only ever return the non-trashed items. A new method `deleted` (or `trashed` to stick with your terminology) should handle getting the trashed items.

2. The admin manager should be set to use the same manager.

class Admin:
____manager = TrashManager()

SmileyChris, 9 months ago

(of course, the admin manager only needs to be set if you are actually using admin, but it’s the solution to Patrick’s problem)

Collin Anderson, 9 months ago

Nice, although I would like to see something that can undo the last change, or multiple changes to an object, not just recover a deleted object.

Nathan Ostgard, 9 months ago

Yeah, there are definitely limitations to this. It was more of an experiment than anything else. I started adding recursive trashing after I posted this, and I too ran into the problem with the two managers.

I think for it to be done right, the Admin would have to use a different manager that queried ALL objects.

@Amit, Collin:
For advanced tracking of changes and relationships and such, a pickling solution (such as discussed in the thread on Simon’s website) would be much more appropriate.

Dustin, 9 months ago

I had a similar idea about using a field to create an in-between ‘removed’ state. Your method of achieving this is much more cleaner than I imagine mine would have been. Good job!

Tony, 9 months ago

Nice work – does 90% of what people want, and in a neat way!

Douglas Jarquin, 9 months ago

Thanks Nathan, this is exactly what I needed. I was fumbling over which method to use but you just sold me.

Nathan Ostgard, 9 months ago

Glad to hear it helped!

anarsist, 4 months ago

Good idea, Favo. I didn’t think of using a decorator.

Alex, 2 months ago

Yeah, there are definitely limitations to this. It was more of an experiment than anything else. I started adding recursive trashing after I posted this, and I too ran into the problem with the two managers.

felsefe, 2 months ago

Looks very interesting.
Thanks for article.

Gazeteler Dergiler, 2 months ago

That’s exactly what i need. Thanks.

örgü, 2 months ago

Thank for the useful information.

kevin, 2 months ago

this method works great. it’s exactly what I wanted. good job

edebiyat, 2 months ago

Looks very interesting. Thanks for article.

örgü, 2 months ago

Thank for the useful article. Regards…

forum, about 1 month ago

Looks great!

ilköğretim, about 1 month ago

Nice work, thanks…

Jo Presse, about 1 month ago

I like that idea. On top of that, you could use a default manager which overrides the core get_query_set method to filter out the deleted items automatically.

bilim adamları, about 1 month ago

Very useful, thanks for the information. I have added this website to my bookmarks.

anarsizm, about 1 month ago

Interesting. I think for it to be done right, the Admin would have to use a different manager that queried ALL objects.

teknoloji ve tasarım, about 1 month ago

good article and useful..so thanks…

bilim adamları ve icatları, about 1 month ago

“That’s exactly what i need. Thanks.” hey guy. so great sentences… thanks

teknoloji ve tasarım, about 1 month ago

i hope you can help me..

icatlar, about 1 month ago

that is a good article…

siyaset, about 1 month ago

Looks very interesting.Thanks for article.

sinema, about 1 month ago

Thank for the useful information…

fitness, about 1 month ago

I love Django and your solution was helpfull for me. It was just exactly what I needed.

Tai Lee, about 1 month ago

I just tried to implement something similar to this. You will run into problems if you have any foreign keys pointing to a “trashed” item. E.g.

class Client(models.Model):
name = models.CharField(max_length=50)

class Job(models.Model):
client = models.ForeignKey(Client)
name = models.CharField(max_length=50)

If you “trash” any client and then try Job.objects.all() you’ll get an error “no Client matching query exists”. This is because the Client manager is filtering it out.

One solution would be to recursively “trash” all related objects effectively removing the jobs from Job.objects.all() as well.

Another problem is that if you have any unique fields (e.g. Client.name), you won’t be able to create a new Client with the same name as a trashed Client. This could be seen as a feature depending on which way you look at it, but you would need to catch those cases and explain the situation to the user.

There is one last problem where the *_set attributes on models which are referenced by other models use whichever Manager is declared first in your model. In your examples it’s the NonTrashManager. That means that even if you recursively “trashed” a client and it’s related jobs, then got your trashed client from Client.trash.get(pk=whatever), that client’s job_set would always be empty because there are no non-trashed jobs referencing trashed clients.

After all that, I’m leaning more towards either pickling and storing an object and it’s related objects in a session like the django-undo module, or pickling and storing an object and it’s related objects in a Revision model so you can generate a change log and undo multiple deletes or revert to any previous revision.

Clint, 26 days ago

Great work! I had a similar idea of using a decorator.

Webdesign Köln, 23 days ago

Nice work, thanks…

Annem Dizisi, 23 days ago

Really great job thank you very very much.

Suchmaschinenoptimierung, 23 days ago

Thank you very much.
Nice, although I would like to see something that can undo the last change, or multiple changes to an object, not just recover a deleted object.

Games, 18 days ago

Thanks for this nice article

Webdesign Hamburg, 18 days ago

Wow, the syntax looks like Ruby! But it’s not, is it?

Jerrry, 13 days ago

i like this conception. it works great

lingerie, 10 days ago

Another problem is that if you have any unique fields (e.g. Client.name), you won’t be able to create a new Client with the same name as a trashed Client. This could be seen as a feature depending on which way you look at it, but you would need to catch those cases and explain the situation to the user.

sex toys, 10 days ago

I’m leaning more towards either pickling and storing an object and it’s related objects in a session like the django-undo module, or pickling and storing an object and it’s related objects in a Revision model so you can generate a change log and undo multiple deletes or revert to any previous revision.

Play Super Mario, 7 days ago

Great article. Thanks for it.

shrimp lover, 7 days ago

Thank, I will try it out later

Military Myspace, 7 days ago

Thanks for the great information.

kuaför malzemeleri, 5 days ago

examples were very usefull to understand properly wish to see more samples.

Post a comment