Skip to content

Commit

Permalink
Merge ddc7606 into 292b9a3
Browse files Browse the repository at this point in the history
  • Loading branch information
GustavoNagel committed Feb 26, 2022
2 parents 292b9a3 + ddc7606 commit 767c246
Show file tree
Hide file tree
Showing 7 changed files with 117 additions and 11 deletions.
5 changes: 3 additions & 2 deletions docs/models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ The different policies are:


Policies Delete Logic Customization
--------
-----------------------------------

Each of the policies has an overwritable function in case you need to customize a particular policy delete logic. The function per policy are as follows:

Expand All @@ -70,7 +70,8 @@ Each of the policies has an overwritable function in case you need to customize
* - SOFT_DELETE_CASCADE
- soft_delete_cascade_policy_action

Example:
Example:

To add custom logic before or after the execution of the original delete logic of a model with the policy SOFT_DELETE you can overwrite the ``soft_delete_policy_action`` function as such:

.. code-block:: python
Expand Down
1 change: 1 addition & 0 deletions safedelete/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
DELETED_ONLY_VISIBLE = 12
DELETED_VISIBLE = 13
FIELD_NAME = getattr(settings, 'SAFE_DELETE_FIELD_NAME', 'deleted')
DELETED_BY_CASCADE_FIELD_NAME = getattr(settings, 'SAFE_DELETE_CASCADED_FIELD_NAME', 'deleted_by_cascade')
29 changes: 26 additions & 3 deletions safedelete/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
FIELD_NAME,
HARD_DELETE,
HARD_DELETE_NOCASCADE,
DELETED_BY_CASCADE_FIELD_NAME,
NO_DELETE,
SOFT_DELETE,
SOFT_DELETE_CASCADE,
Expand Down Expand Up @@ -52,6 +53,21 @@ class SafeDeleteModel(models.Model):
DateTimeField set to the moment the object was deleted. Is set to
``None`` if the object has not been deleted.
:attribute deleted_by_cascade:
BooleanField set True whenever the object is deleted due cascade operation called by delete
method of any parent Model. Default value is False. Later if its parent model calls for
cascading undelete, it will restore only child classes that were also deleted by a cascading
operation (deleted_by_cascade equals to True), i.e. all objects that were deleted before their
parent deletion, should keep deleted if the same parent object is restored by undelete method.
If this behavior isn't desired, class that inherits from SafeDeleteModel can override this
attribute by setting it as None: overriding model class won't have its ``deleted_by_cascade``
field and will be restored by cascading undelete even if it wasn't deleted by a cascade operation.
>>> class MyModel(SafeDeleteModel):
... deleted_by_cascade = None
... my_field = models.TextField()
:attribute _safedelete_policy: define what happens when you delete an object.
It can be one of ``HARD_DELETE``, ``SOFT_DELETE``, ``SOFT_DELETE_CASCADE``, ``NO_DELETE`` and ``HARD_DELETE_NOCASCADE``.
Defaults to ``SOFT_DELETE``.
Expand Down Expand Up @@ -103,6 +119,7 @@ def save(self, keep_deleted=False, **kwargs):
if getattr(self, FIELD_NAME) and self.pk:
was_undeleted = True
setattr(self, FIELD_NAME, None)
setattr(self, DELETED_BY_CASCADE_FIELD_NAME, False)

super(SafeDeleteModel, self).save(**kwargs)

Expand All @@ -127,9 +144,9 @@ def undelete(self, force_policy=None, **kwargs):
self.save(keep_deleted=False, **kwargs)

if current_policy == SOFT_DELETE_CASCADE:
for related in related_objects(self):
for related in related_objects(self, only_deleted_by_cascade=True):
if is_safedelete_cls(related.__class__) and getattr(related, FIELD_NAME):
related.undelete()
related.undelete(**kwargs)

def delete(self, force_policy=None, **kwargs):
# To know why we need to do that, see https://github.com/makinacorpus/django-safedelete/issues/117
Expand Down Expand Up @@ -158,6 +175,11 @@ def _delete(self, force_policy=None, **kwargs):
def soft_delete_policy_action(self, **kwargs):
# Only soft-delete the object, marking it as deleted.
setattr(self, FIELD_NAME, timezone.now())

# is_cascade shouldn't be in kwargs when calling save method.
if kwargs.pop('is_cascade', None):
setattr(self, DELETED_BY_CASCADE_FIELD_NAME, True)

using = kwargs.get('using') or router.db_for_write(self.__class__, instance=self)
# send pre_softdelete signal
pre_softdelete.send(sender=self.__class__, instance=self, using=using)
Expand All @@ -180,7 +202,7 @@ def soft_delete_cascade_policy_action(self, **kwargs):
# Soft-delete on related objects before
for related in related_objects(self):
if is_safedelete_cls(related.__class__) and not getattr(related, FIELD_NAME):
related.delete(force_policy=SOFT_DELETE, **kwargs)
related.delete(force_policy=SOFT_DELETE, is_cascade=True, **kwargs)

# soft-delete the object
self._delete(force_policy=SOFT_DELETE, **kwargs)
Expand Down Expand Up @@ -262,6 +284,7 @@ def _perform_unique_checks(self, unique_checks):


SafeDeleteModel.add_to_class(FIELD_NAME, models.DateTimeField(editable=False, null=True))
SafeDeleteModel.add_to_class(DELETED_BY_CASCADE_FIELD_NAME, models.BooleanField(editable=False, default=False))


class SafeDeleteMixin(SafeDeleteModel):
Expand Down
2 changes: 2 additions & 0 deletions safedelete/tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@

SAFE_DELETE_FIELD_NAME = ''.join(random.choice(string.ascii_uppercase) for i in range(10))

SAFE_DELETE_CASCADED_FIELD_NAME = ''.join(random.choice(string.ascii_uppercase) for i in range(8))

# This is for Django 3.2, harmless for previous versions.
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'

Expand Down
2 changes: 1 addition & 1 deletion safedelete/tests/test_soft_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def test_undelete_with_soft_delete_policy_and_forced_soft_delete_cascade_policy(

SoftDeleteModel.deleted_objects.all().undelete(force_policy=SOFT_DELETE_CASCADE)
self.assertEqual(SoftDeleteModel.objects.count(), 1)
self.assertEqual(SoftDeleteRelatedModel.objects.count(), 1)
self.assertEqual(SoftDeleteRelatedModel.objects.count(), 0)

def test_validate_unique(self):
"""Check that uniqueness is also checked against deleted objects """
Expand Down
57 changes: 57 additions & 0 deletions safedelete/tests/test_soft_delete_cascade.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from django.db import models
from django.test import TestCase
from django.core.exceptions import FieldError

from safedelete import SOFT_DELETE, SOFT_DELETE_CASCADE
from safedelete.config import DELETED_BY_CASCADE_FIELD_NAME
from safedelete.models import SafeDeleteModel
from safedelete.signals import pre_softdelete
from safedelete.tests.models import Article, Author, Category
Expand All @@ -17,6 +19,17 @@ class Press(SafeDeleteModel):
article = models.ForeignKey(Article, on_delete=models.CASCADE)


class Section(SafeDeleteModel):
title = models.CharField(max_length=200)
article = models.ForeignKey(Article, on_delete=models.CASCADE)


class Table(SafeDeleteModel):
index = models.IntegerField()
section = models.ForeignKey(Section, on_delete=models.CASCADE)
vars()[DELETED_BY_CASCADE_FIELD_NAME] = None


class PressNormalModel(models.Model):
name = models.CharField(max_length=200)
article = models.ForeignKey(Article, on_delete=models.CASCADE, null=True)
Expand Down Expand Up @@ -64,6 +77,18 @@ def setUp(self):
Press.objects.create(name='press 0', article=self.articles[2])
)

self.sections = (
Section.objects.create(title='Abstract', article=self.articles[2]),
Section.objects.create(title='Methods', article=self.articles[2]),
Section.objects.create(title='Results', article=self.articles[2]),
)

self.tables = (
Table.objects.create(index=1, section=self.sections[1]),
Table.objects.create(index=1, section=self.sections[2]),
Table.objects.create(index=1, section=self.sections[2]),
)

def test_soft_delete_cascade(self):
self.assertEqual(Author.objects.count(), 3)
self.assertEqual(Article.objects.count(), 3)
Expand Down Expand Up @@ -151,3 +176,35 @@ def test_undelete_with_soft_delete_cascade_policy(self):
self.assertEqual(Article.objects.count(), 3)
self.assertEqual(Category.objects.count(), 3)
self.assertEqual(Press.objects.count(), 1)

def test_undelete_with_cascade_control_class_included(self):
self.sections[1].delete(force_policy=SOFT_DELETE_CASCADE)

self.assertEqual(Section.objects.count(), 2)
self.assertEqual(Table.objects.count(), 2)

self.authors[2].delete(force_policy=SOFT_DELETE_CASCADE)

self.assertEqual(Article.objects.count(), 2)
self.assertEqual(Press.objects.count(), 0)
self.assertEqual(Section.objects.count(), 0)
self.assertEqual(Section.deleted_objects.filter(**{DELETED_BY_CASCADE_FIELD_NAME: True}).count(), 2)

self.authors[2].undelete(force_policy=SOFT_DELETE_CASCADE)

self.assertEqual(Article.objects.count(), 3)
self.assertEqual(Press.objects.count(), 1)
self.assertEqual(Section.objects.filter(**{DELETED_BY_CASCADE_FIELD_NAME: False}).count(), 2)
self.assertEqual(Table.objects.count(), 2)
self.assertEqual(self.sections[1], Section.deleted_objects.first())

def test_safe_delete_cascade_control_attribute_overriding(self):

with self.assertRaises(FieldError):
Table.objects.filter(**{DELETED_BY_CASCADE_FIELD_NAME: False})

self.tables[2].delete()
self.sections[2].delete(force_policy=SOFT_DELETE_CASCADE)
self.assertEqual(Table.objects.count(), 1)
self.sections[2].undelete(force_policy=SOFT_DELETE_CASCADE)
self.assertEqual(Table.objects.count(), 3)
32 changes: 27 additions & 5 deletions safedelete/utils.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,45 @@
import itertools
from itertools import chain, filterfalse

from django.contrib.admin.utils import NestedObjects
from django.db import router

from .config import DELETED_BY_CASCADE_FIELD_NAME

def related_objects(obj):
""" Return a generator to the objects that would be deleted if we delete "obj" (excluding obj) """

def related_objects(obj, only_deleted_by_cascade=False):
""" Return a generator to the objects that would be deleted if we delete "obj" (excluding obj)
Args:
only_deleted_by_cascade: Include filter in flatten method to bypass elements controling undelete cascading.
"""

collector = NestedObjects(using=router.db_for_write(obj))
collector.collect([obj])

def cascade_undelete_bypass(elem):
""" Test if elem should be bypassed by cascade undelete """

return elem != obj and getattr(elem, DELETED_BY_CASCADE_FIELD_NAME) is False

def replace_if_cascade_child(elem):
""" Wraps each item in elem with tuples including their cascading child classes """

if collector.edges.get(elem):
return (elem, collector.edges[elem])
else:
return (elem,)

def flatten(elem):
if isinstance(elem, list):
return itertools.chain.from_iterable(map(flatten, elem))
if only_deleted_by_cascade:
elem = filterfalse(cascade_undelete_bypass, elem)
expanded_elem = chain.from_iterable(map(replace_if_cascade_child, elem))
return chain.from_iterable(map(flatten, expanded_elem))
elif obj != elem:
return (elem,)
return ()

return flatten(collector.nested())
return flatten([obj])


def can_hard_delete(obj):
Expand Down

0 comments on commit 767c246

Please sign in to comment.