import warnings
import django
from django.contrib.admin.utils import NestedObjects
from django.db import models, router
from django.db.models import UniqueConstraint
from django.utils import timezone
from .config import (
FIELD_NAME,
HARD_DELETE,
HARD_DELETE_NOCASCADE,
DELETED_BY_CASCADE_FIELD_NAME,
NO_DELETE,
SOFT_DELETE,
SOFT_DELETE_CASCADE,
)
from .managers import (
SafeDeleteAllManager,
SafeDeleteDeletedManager,
SafeDeleteManager,
)
from .signals import post_softdelete, post_undelete, pre_softdelete
from .utils import can_hard_delete, related_objects
def is_safedelete_cls(cls):
for base in cls.__bases__:
# This used to check if it startswith 'safedelete', but that masks
# the issue inside of a test. Other clients create models that are
# outside of the safedelete package.
if base.__module__.startswith('safedelete.models'):
return True
if is_safedelete_cls(base):
return True
return False
def is_safedelete(related):
warnings.warn(
'is_safedelete is deprecated in favor of is_safedelete_cls',
DeprecationWarning)
return is_safedelete_cls(related.__class__)
[docs]class SafeDeleteModel(models.Model):
"""Abstract safedelete-ready model.
.. note::
To create your safedelete-ready models, you have to make them inherit from this model.
:attribute deleted:
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 won't be restored by cascading undelete even if it was 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``.
>>> class MyModel(SafeDeleteModel):
... _safedelete_policy = SOFT_DELETE
... my_field = models.TextField()
...
>>> # Now you have your model (with its ``deleted`` field, and custom manager and delete method)
:attribute objects:
The :class:`safedelete.managers.SafeDeleteManager` returns the non-deleted models.
:attribute all_objects:
The :class:`safedelete.managers.SafeDeleteAllManager` returns all the models (non-deleted and soft-deleted).
:attribute deleted_objects:
The :class:`safedelete.managers.SafeDeleteDeletedManager` returns the soft-deleted models.
"""
_safedelete_policy = SOFT_DELETE
objects = SafeDeleteManager()
all_objects = SafeDeleteAllManager()
deleted_objects = SafeDeleteDeletedManager()
class Meta:
abstract = True
[docs] def save(self, keep_deleted=False, **kwargs):
"""Save an object, un-deleting it if it was deleted.
Args:
keep_deleted: Do not undelete the model if soft-deleted. (default: {False})
kwargs: Passed onto :func:`save`.
.. note::
Undeletes soft-deleted models by default.
"""
# undelete signal has to happen here (and not in undelete)
# in order to catch the case where a deleted model becomes
# implicitly undeleted on-save. If someone manually nulls out
# deleted, it'll bypass this logic, which I think is fine, because
# otherwise we'd have to shadow field changes to handle that case.
was_undeleted = False
if not keep_deleted:
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)
if was_undeleted:
# send undelete signal
using = kwargs.get('using') or router.db_for_write(self.__class__, instance=self)
post_undelete.send(sender=self.__class__, instance=self, using=using)
[docs] def undelete(self, force_policy=None, **kwargs):
"""Undelete a soft-deleted model.
Args:
force_policy: Force a specific undelete policy. (default: {None})
kwargs: Passed onto :func:`save`.
.. note::
Will raise a :class:`AssertionError` if the model was not soft-deleted.
"""
current_policy = force_policy or self._safedelete_policy
assert getattr(self, FIELD_NAME)
self.save(keep_deleted=False, **kwargs)
if current_policy == SOFT_DELETE_CASCADE:
for related in related_objects(self, only_deleted_by_cascade=True):
if is_safedelete_cls(related.__class__) and getattr(related, FIELD_NAME):
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
self._delete(force_policy, **kwargs)
def _delete(self, force_policy=None, **kwargs):
"""Overrides Django's delete behaviour based on the model's delete policy.
Args:
force_policy: Force a specific delete policy. (default: {None})
kwargs: Passed onto :func:`save` if soft deleted.
"""
current_policy = self._safedelete_policy if (force_policy is None) else force_policy
if current_policy == NO_DELETE:
pass
elif current_policy == SOFT_DELETE:
self.soft_delete_policy_action(**kwargs)
elif current_policy == HARD_DELETE:
self.hard_delete_policy_action(**kwargs)
elif current_policy == HARD_DELETE_NOCASCADE:
self.hard_delete_cascade_policy_action(**kwargs)
elif current_policy == SOFT_DELETE_CASCADE:
self.soft_delete_cascade_policy_action(**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', False):
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)
self.save(keep_deleted=True, **kwargs)
# send softdelete signal
post_softdelete.send(sender=self.__class__, instance=self, using=using)
def hard_delete_policy_action(self, **kwargs):
# Normally hard-delete the object.
super(SafeDeleteModel, self).delete()
def hard_delete_cascade_policy_action(self, **kwargs):
# Hard-delete the object only if nothing would be deleted with it
if not can_hard_delete(self):
self._delete(force_policy=SOFT_DELETE, **kwargs)
else:
self._delete(force_policy=HARD_DELETE, **kwargs)
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, is_cascade=True, **kwargs)
# soft-delete the object
self._delete(force_policy=SOFT_DELETE, **kwargs)
collector = NestedObjects(using=router.db_for_write(self))
collector.collect([self])
# update fields (SET, SET_DEFAULT or SET_NULL)
for model, instances_for_fieldvalues in collector.field_updates.items():
for (field, value), instances in instances_for_fieldvalues.items():
query = models.sql.UpdateQuery(model)
query.update_batch(
[obj.pk for obj in instances],
{field.name: value},
collector.using,
)
[docs] @classmethod
def has_unique_fields(cls):
"""Checks if one of the fields of this model has a unique constraint set (unique=True).
It also checks if the model has sets of field names that, taken together, must be unique.
Args:
model: Model instance to check
"""
if cls._meta.unique_together:
return True
if django.VERSION[0] > 3 or (django.VERSION[0] == 3 and django.VERSION[1] >= 1):
if cls._meta.total_unique_constraints:
return True
else: # derived from total_unique_constraints in django >= 3.1
for constraint in cls._meta.constraints:
if isinstance(constraint, UniqueConstraint) and constraint.condition is None:
return True
for field in cls._meta.fields:
if field._unique:
return True
return False
# We need to overwrite this check to ensure uniqueness is also checked
# against "deleted" (but still in db) objects.
# FIXME: Better/cleaner way ?
def _perform_unique_checks(self, unique_checks):
errors = {}
for model_class, unique_check in unique_checks:
lookup_kwargs = {}
for field_name in unique_check:
f = self._meta.get_field(field_name)
lookup_value = getattr(self, f.attname)
if lookup_value is None:
continue
if f.primary_key and not self._state.adding:
continue
lookup_kwargs[str(field_name)] = lookup_value
if len(unique_check) != len(lookup_kwargs):
continue
# This is the changed line
if hasattr(model_class, 'all_objects'):
qs = model_class.all_objects.filter(**lookup_kwargs)
else:
qs = model_class._default_manager.filter(**lookup_kwargs)
model_class_pk = self._get_pk_val(model_class._meta)
if not self._state.adding and model_class_pk is not None:
qs = qs.exclude(pk=model_class_pk)
if qs.exists():
if len(unique_check) == 1:
key = unique_check[0]
else:
key = models.base.NON_FIELD_ERRORS
errors.setdefault(key, []).append(
self.unique_error_message(model_class, unique_check)
)
return errors
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))
[docs]class SafeDeleteMixin(SafeDeleteModel):
"""``SafeDeleteModel`` was previously named ``SafeDeleteMixin``.
.. deprecated:: 0.4.0
Use :class:`SafeDeleteModel` instead.
"""
class Meta:
abstract = True
def __init__(self, *args, **kwargs):
warnings.warn('The SafeDeleteMixin class was renamed SafeDeleteModel',
DeprecationWarning)
SafeDeleteModel.__init__(self, *args, **kwargs)