16

I am trying to implement a general soft deletion pattern for Django models.

Models are given a is_deleted field, which keeps deleted objects in DB, but hides them for all practical purposes: all normal rules for cascading etc. should be followed, except for actual removal. The admin app, however, should still be able to work with deleted objects, for the purpose of either erasing (definitely throw them out) o restoring them. (See code below)

Problem: this breaks cascading. What I had expected to happen was cascading to occur through the methods I overrode on models and custom queryset. What actually happens is that they are instead bypassed by the default queryset/manager which also happens to be using a fast _raw_delete internal API. So either cascaded delete does not happen, or if I call the super().delete() method on my model (and save() after that), standard delete is performed on related objects.

I have tried what is suggested in Cascading Delete w/ Custom Model Delete Method, but this breaks things horribly - besides it advocates usage of the deprecated use_for_related_fields manager attribute.

I am beginning to think that what I want to achieve is not possible without effecting major dismemeberments of Django's privates - weird, since this soft deletion behavior is a standard pattern in many DBMS situations.

This is where I am at now:

  • I created a custom manager and query set for objects with a is_deleted field:

    from django.db import models
    from django.db.models.query import QuerySet
    
    
    class SoftDeleteQuerySet(QuerySet):
        #https://stackoverflow.com/questions/28896237/override-djangos-model-delete-method-for-bulk-deletion
        def __init__(self,*args,**kwargs):
            return super(self.__class__,self).__init__(*args,**kwargs)
    
        def delete(self,*args,**kwargs):
            for obj in self: obj.delete()
    
    #http://codespatter.com/2009/07/01/django-model-manager-soft-delete-how-to-customize-admin/
    # but use get_queryset,  not get_query_set !!!
    class SoftDeleteManager(models.Manager):
        """ Use this manager to get objects that have a is_deleted field """
        def get_queryset(self,*args,**kwargs):
            return SoftDeleteQuerySet(model=self.model, using=self._db, hints=self._hints).filter(is_deleted=False)
    
        def all_with_deleted(self,*args,**kwargs):
            return SoftDeleteQuerySet(model=self.model, using=self._db, hints=self._hints).filter()
    
        def deleted_set(self,*args,**kwargs):
            return SoftDeleteQuerySet(model=self.model, using=self._db, hints=self._hints).filter(is_deleted=True)
    
        def get(self, *args, **kwargs):
            """ if a specific record was requested, return it even if it's deleted """
            return self.all_with_deleted().get(*args, **kwargs)
    
        def filter(self, *args, **kwargs):
            """ if pk was specified as a kwarg, return even if it's deleted """
            if 'pk' in kwargs:
                return self.all_with_deleted().filter(*args, **kwargs)
            return self.get_queryset().filter(*args, **kwargs)
    
  • Added a base model to use it:

    class SoftDeleteModel(models.Model):
    
        objects=SoftDeleteManager()
        is_deleted   = models.BooleanField(default=False, verbose_name="Is Deleted")
    
        def delete(self,*args,**kwargs):
            if self.is_deleted : return
            self.is_deleted=True
            self.save()
    
    
        def erase(self,*args,**kwargs):
            """
            Actually delete from database.
            """
            super(SoftDeleteModel,self).delete(*args,**kwargs)
    
        def restore(self,*args,**kwargs):
            if not self.deleted: return
            self.is_deleted=False
            self.save()
    
    
        def __unicode__(self): return "%r %s of %s"%(self.__class__,str(self.id))
    
        class Meta:
            abstract = True
    
  • And admin classes to handle the erasure, restore, etc:

    # for definitive deletion of models in admin
    def erase_model(modeladmin,request,queryset):
        """
        Completely remove models from db
        """
        for obj in queryset:
            obj.erase(user=request.user)
    
    def restore_model(modeladmin,request,queryset):
        """
        Restore a softdeletd model set 
        """
        for obj in queryset:
            obj.restore(user=request.user)
    
    #http://codespatter.com/2009/07/01/django-model-manager-soft-delete-how-to-customize-admin/        
    # but the method is now get_queryset.
    
    class SoftDeleteAdmin(admin.ModelAdmin):
        list_display = ('pk', '__unicode__', 'is_deleted',)
        list_filter = ('is_deleted',)
        actions=[erase_model, restore_model]
    
        def get_queryset(self, request):
            """ Returns a QuerySet of all model instances that can be edited by the
            admin site. This is used by changelist_view. """
            # Default: qs = self.model._default_manager.get_query_set()
            qs = self.model._default_manager.all_with_deleted()
            #TR()
            # TODO: this should be handled by some parameter to the ChangeList.
            ordering = self.ordering or () # otherwise we might try to *None, which is bad ;)
            if ordering:
                qs = qs.order_by(*ordering)
            return qs
    
        queryset=get_queryset
    

Ideas?

EDIT: The takeaway of all this (other than searching more thouroughly for packaged solutions :-) ) is that overriding delete and getting it right can be done, but it ain't easy, of for the faint of hart. The package I am going to use - django-softdelete, an evolution of my starting point, ripped from http://codespatter.com/2009/07/01/django-model-manager-soft-delete-how-to-customize-admin/ - uses a ChangeSet computed through the Contenttype API.

Short of all that, there are several situations where the overriden delete() is not called at all (basically, every time a group deletion occurs, django takes shortcuts that jump over the head of model.delete()).

Which is, in my humble opinion, a design blunder. If overriding it takes such quantities of brain explodium, model.delete() should actually be model._delete().

Alien Life Form
  • 1,884
  • 1
  • 19
  • 27
  • may be Django signals will help you somehow? specially `pre_delete` or `post_delete` signals. – Chiefir May 07 '18 at 12:50
  • 2
    There's a library that is supposed to implement cascading soft delete: [django-softdelete](https://github.com/scoursen/django-softdelete). You can take a look, maybe find some inspiration or just use it as-is. Disclaimer: I have no idea about it's quality or maintanance status. – Frax May 07 '18 at 13:29
  • @ChiefirThat's what I am exploring right now - but from the looks of it, because I never call the base class delete, I never trigger the signals - unless I send them myself, but I can do it only for objects where my method is called... – Alien Life Form May 07 '18 at 14:26
  • @Frax I am looking at it right now and it is indeed a descendent of the code that got me started on this, and it may be just what I was looking for. I should have looked better before getting the editor out. – Alien Life Form May 07 '18 at 15:00
  • 1
    Another library, recommended by the Two Scoops book: [django-model-utils](https://django-model-utils.readthedocs.io/en/latest/managers.html#softdeletablemanager) – João Amaro May 07 '18 at 21:14
  • @JoãoAmaro - thanks for the heads up - that library contains some things I was going to write myself (even if I'll likely forego their softdelete implementation) – Alien Life Form May 09 '18 at 09:07
  • @Frax I think I'll go with your sugggestion - if you turn your comment in an answer I will accept it. – Alien Life Form May 09 '18 at 09:40
  • @AlienLifeForm I added the answer. – Frax May 09 '18 at 15:55

2 Answers2

4

django-softdelete is a library implementing soft delete for Django, with cascading. It also stores the changesets, and allows reverting the deletions (e.g. reverting the whole cascade).

I'm not sure what is it's maintenance status and quality, but it can at least serve as inspiration, if not a solution by itself.

Frax
  • 5,015
  • 2
  • 17
  • 19
  • how to do undelete and query the deleted items with django-softdelete? – Ali Husham Jul 07 '21 at 09:57
  • 1
    I'm not sure, I have never used it. By a quick look at the code I assume you can do stuff like `MyModel.objects.all_with_deleted().filter(foo='bar').undelete()` or `MyModel.objects.get(pk=12).undelete()` (.get() works specifically with `pk`, `.get(id=12)` apparently wouldn't work). See the `SoftDeleteQuerySet` and `SoftDeleteManager` classes here: https://github.com/scoursen/django-softdelete/blob/master/softdelete/models.py – Frax Jul 07 '21 at 12:36
  • what if I want to get all deleted itsm for all models? – Ali Husham Jul 08 '21 at 09:17
  • 1
    It seems that you can get deleted objects for any model with `MyModel.objects.deleted_set()`. I guess that you can do some query on `SoftDeleteRecord` to get all deleted objects in all tables, but I don't really see why you would want that instead of quering specific models. – Frax Jul 08 '21 at 10:12
  • I want to make a trash app just like the `trash` or `trash bin` on mac or in windows. there is no such `SoftDeleteRecord` class in `django-softdelete` – Ali Husham Jul 08 '21 at 13:00
4

Maybe you can use django-paranoid.

It is similar to acts_as_paranoid for rails and is easy to use.

You only need to extend your model with ParanoidModel.

To retrieve the deleted object, you use the objects_with_deleted manager:

MyModel.objects_with_deleted.last()

and if you want to perform a hard delete on an object, use the True parameter:

m = MyModel.objects.last()
m.delete(True)
Mathieu Dhondt
  • 8,405
  • 5
  • 37
  • 58
dr. Neox
  • 401
  • 4
  • 6