(Django) Customized Soft-Deletion Cascade

Nicholas An
4 min readMar 30, 2021

A while ago, I was given a huge task to implement soft deletion on several models of our app. I was going to just use the existing Django package that automatically handles soft deletion, but since my co-worker already made his own soft deletion for one model in the app, the package would cause some complications regarding this model. I could have gone ahead and changed that very model in question so that I could apply this convenient package, but then I wanted to do this on my own somehow…

And thus, I created a separate directory and a file within it because utils.py in our default project directory was getting enormously huge and complicated. Everything in utils.py got moved to utils/utils.py, and I created soft_cascade.py in the utils directory.

default project directory (tradeforce)

Let’s take a look at the alteration I made with the models to be applied soft-deletion. Since there is a lot going on, I replaced all the fields unrelated to this post with periods.

class Board(models.Model, ReusableModelMethodsMixin):
.
.
.
is_deleted = models.BooleanField(default=False)
objects = ReusableQuerySetMethods.as_manager()
deleted = DeletedManager()
.
.
.
class Meta:
db_table = 'boards'
base_manager_name = 'objects'
ordering = ['created_at']
.
.
.
class CardQuerySet(ReusableQuerySetMethods):
''' Card QuerySet '''

def archived(self):
""" return card objects that have been archived """
return self.filter(is_archived=True)
.
.
.
class Card(models.Model, ReusableModelMethodsMixin):
.
.
.
objects = CardQuerySet.as_manager()
deleted = DeletedManager()
is_deleted = models.BooleanField(default=False)
.
.
.
class Meta:
db_table = 'cards'
base_manager_name = 'objects'

Notice that the model classes have is_deleted which is boolean. This field decides whether an instance is deleted or not without actually deleting it. When someone decides to delete a board instance via API, (the destroy function of which is switched from .delete() to .soft_delete()) that act won’t delete this instance, but instead, switch this instance’s is_delete status from False to True. Nothing disappeared from the database.

However, the problem occurs when we are trying to use querysets to bring about a set of active boards. At my previous company, they were just using Board.objects.filter(is_deleted=False) on every single related APIs and etc. This requires a lot of attention in itself, making it hard to maintain. If you ever forget to write Board.obejects.filter(is_deleted=False) in any APIs, it will mindlessly show all the instances regardless of whether it got soft-deleted or not. And we don’t want that…

I implemented a solution to automatically exclude is_deleted=True when using the default manager, which is objects. One way to do this is to use custom querysets. Notice that objects was overridden by declaring objects = ReusableQuerySetMethods.as_manager().

Now let’s take a look at all the things related to this soft-deletion.

soft_cascade.py

from contextlib import suppress

from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction, models
from django.db.models import ForeignObjectRel


def find_reversely_related_models(instance):
links = [field.get_accessor_name() for field in instance._meta.get_fields()
if (issubclass(type(field), ForeignObjectRel)
and hasattr(field.related_model, 'has_soft_delete'))]
links_for_generic = [field.name for field in instance._meta.get_fields()
if (issubclass(type(field), GenericRelation)
and hasattr(field.related_model, 'has_soft_delete'))]

return links + links_for_generic

def related_objs_soft_cascade(instance):
links = find_reversely_related_models(instance)
if not links:
return
for link in links:
if ( hasattr(instance, link) and
issubclass(eval(f'instance.{link}.__class__'), models.base.Model) ):
with suppress(ObjectDoesNotExist, AttributeError):
exec(f'instance.{link}.soft_delete()')
else:
with suppress(ObjectDoesNotExist, AttributeError):
exec(f'instance.{link}.all().qs_soft_delete()')

class ReusableModelMethodsMixin:

def soft_delete(self):
with transaction.atomic():
self.is_deleted = True
self.save()

related_objs_soft_cascade(self)

# helps us pick the models with soft_delete() method.
has_soft_delete = True


class ReusableQuerySetMethods(models.query.QuerySet):

@classmethod
def as_manager(cls):
manager = SoftDeleteManager.from_queryset(cls)()
manager._built_with_as_manager = True
return manager

def qs_soft_delete(self, *args, **kwargs):
for instance in self:
instance.soft_delete()
self._result_cache = None
qs_soft_delete.alters_data = True


class SoftDeleteManager(models.Manager):

def get_queryset(self):
return super().get_queryset().filter(is_deleted=False)


class DeletedManager(models.Manager):

def get_queryset(self):
return super().get_queryset().filter(is_deleted=True)

Notice that the ReusableQuerySetMethods overrides its parent class’s as_manager method. The method, manager = SoftDeleteManager.from_queryset(cls)() tells itself that the manager now derives from SoftDeleteManager Manager class. This SoftDeleteManager overrides the get_queryset method and adds on a filter: filter(is_deleted=False).

Now, take a look at def soft_delete() in ReusableModelMethodsMixin. The mixin’s soft_delete() will be available for every single model that inherits ReusableModelMethodsMixin.

Here is the most interesting part: cascading. When soft_delete launches, it eventually goes to related_objs_soft_cascade. related_objs_soft_cascade then inspects every instance and sees if any of them has reversely-related objects that also have soft_delete(). If they find an object that has many reversely-related objects, that is_deleted of which must be changed to True, then it will initiate qs_soft_delete() in ReusableQuerySetMethods. qs_soft_delete() runs a for-loop to run soft_delete() on every related instance, which then goes on to related_objs_soft_cascade and on to qs_soft_delete(), and so on…

Let’s say there is a board. And there are two card lists, each of which contains 10 cards. If you decide to delete the board, it will run soft_delete() on that instance, which then runs related_objs_soft_cascade, which runs qs_soft_delete() on card lists queryset. Then each card list will run soft_delete(). Each of which will run related_objs_soft_cascade then qs_soft_delete(). This initiates soft_delete() on every single related card instance.

--

--