(Django) Customized Soft-Deletion Cascade
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.

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.