diff --git a/AUTHORS.rst b/AUTHORS.rst index 1ba4b8f..8faaddd 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -40,3 +40,4 @@ Travis Swicegood Trey Hunner Karl Wan Nan Wo zyegfryed +Radosław Jan Ganczarek diff --git a/CHANGES.rst b/CHANGES.rst index 131a7fb..61fe7b8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,9 @@ CHANGES master (unreleased) ------------------- +* Added `SoftDeletableModel` abstract class, its manageer + `SoftDeletableManager` and queryset `SoftDeletableQuerySet`. + * Fix issue with field tracker and deferred FileField for Django 1.10. diff --git a/docs/managers.rst b/docs/managers.rst index 2669a1c..9a626d2 100644 --- a/docs/managers.rst +++ b/docs/managers.rst @@ -132,6 +132,14 @@ PassThroughManager built-in `QuerySet.as_manager()` and/or `Manager.from_queryset()` utilities instead. + +SoftDeletableManager +-------------------- + +Returns only model instances that have the ``is_removed`` field set +to False. Uses ``SoftDeletableQuerySet``, which ensures model instances +won't be removed in bulk, but they will be marked as removed instead. + Mixins ------ diff --git a/docs/models.rst b/docs/models.rst index 7a05c79..51bde8f 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -47,3 +47,11 @@ returns objects with that status only: # this query will only return published articles: Article.published.all() + + +SoftDeletableModel +------------------ + +This abstract base class just provides field ``is_removed`` which is +set to True instead of removing the instance. Entities returned in +default manager are limited to not-deleted instances. diff --git a/model_utils/managers.py b/model_utils/managers.py index 4f06d2e..c6a2989 100644 --- a/model_utils/managers.py +++ b/model_utils/managers.py @@ -244,3 +244,37 @@ class QueryManagerMixin(object): class QueryManager(QueryManagerMixin, models.Manager): pass + + +class SoftDeletableQuerySet(QuerySet): + """ + QuerySet for SoftDeletableModel. Instead of removing instance sets + its ``is_removed`` field to True. + """ + + def delete(self): + """ + Soft delete objects from queryset (set their ``is_removed`` + field to True) + """ + self.update(is_removed=True) + + +class SoftDeletableManager(models.Manager): + """ + Manager that limits the queryset by default to show only not removed + instances of model. + """ + _queryset_class = SoftDeletableQuerySet + + def get_queryset(self): + """ + Return queryset limited to not removed entries. + """ + kwargs = {'model': self.model, 'using': self._db} + if hasattr(self, '_hints'): + kwargs['hints'] = self._hints + + return SoftDeletableQuerySet(**kwargs).filter(is_removed=False) + + get_query_set = get_queryset diff --git a/model_utils/models.py b/model_utils/models.py index 3db4073..3f7ec8d 100644 --- a/model_utils/models.py +++ b/model_utils/models.py @@ -10,7 +10,7 @@ if django.VERSION >= (1, 9, 0): else: from django.utils.timezone import now -from model_utils.managers import QueryManager +from model_utils.managers import QueryManager, SoftDeletableManager from model_utils.fields import AutoCreatedField, AutoLastModifiedField, \ StatusField, MonitorField @@ -99,3 +99,25 @@ models.signals.class_prepared.connect(add_timeframed_query_manager) def _field_exists(model_class, field_name): return field_name in [f.attname for f in model_class._meta.local_fields] + + +class SoftDeletableModel(models.Model): + """ + An abstract base class model with a ``is_removed`` field that + marks entries that are not going to be used anymore, but are + kept in db for any reason. + Default manager returns only not-removed entries. + """ + is_removed = models.BooleanField(default=False) + + class Meta: + abstract = True + + objects = SoftDeletableManager() + + def delete(self, using=None, keep_parents=False): + """ + Soft delete object (set its ``is_removed`` field to True) + """ + self.is_removed = True + self.save() diff --git a/model_utils/tests/models.py b/model_utils/tests/models.py index c086838..eee735b 100644 --- a/model_utils/tests/models.py +++ b/model_utils/tests/models.py @@ -4,7 +4,12 @@ from django.db import models from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ -from model_utils.models import TimeStampedModel, StatusModel, TimeFramedModel +from model_utils.models import ( + SoftDeletableModel, + StatusModel, + TimeFramedModel, + TimeStampedModel, +) from model_utils.tracker import FieldTracker, ModelTracker from model_utils.managers import QueryManager, InheritanceManager from model_utils.fields import SplitField, MonitorField, StatusField @@ -305,3 +310,13 @@ class StatusFieldDefaultNotFilled(models.Model): class StatusFieldChoicesName(models.Model): NAMED_STATUS = Choices((0, "no", "No"), (1, "yes", "Yes")) status = StatusField(choices_name='NAMED_STATUS') + + +class SoftDeletable(SoftDeletableModel): + """ + Test model with additional manager for full access to model + instances. + """ + name = models.CharField(max_length=20) + + all_objects = models.Manager() diff --git a/model_utils/tests/tests.py b/model_utils/tests/tests.py index 719d185..0c86510 100644 --- a/model_utils/tests/tests.py +++ b/model_utils/tests/tests.py @@ -28,7 +28,8 @@ from model_utils.tests.models import ( ModelTracked, ModelTrackedFK, ModelTrackedNotDefault, ModelTrackedMultiple, InheritedModelTracked, Tracked, TrackedFK, InheritedTrackedFK, TrackedNotDefault, TrackedNonFieldAttr, TrackedMultiple, InheritedTracked, TrackedFileField, StatusFieldDefaultFilled, StatusFieldDefaultNotFilled, - InheritanceManagerTestChild3, StatusFieldChoicesName) + InheritanceManagerTestChild3, StatusFieldChoicesName, + SoftDeletable) class MigrationsTests(TestCase): @@ -1975,3 +1976,31 @@ class InheritedModelTrackerTests(ModelTrackerTests): self.name2 = 'test' self.assertEqual(self.tracker.previous('name2'), None) self.assertTrue(self.tracker.has_changed('name2')) + + +class SoftDeletableModelTests(TestCase): + + def test_can_only_see_not_removed_entries(self): + SoftDeletable.objects.create(name='a', is_removed=True) + SoftDeletable.objects.create(name='b', is_removed=False) + + queryset = SoftDeletable.objects.all() + + self.assertEqual(queryset.count(), 1) + self.assertEqual(queryset[0].name, 'b') + + def test_instance_cannot_be_fully_deleted(self): + instance = SoftDeletable.objects.create(name='a') + + instance.delete() + + self.assertEqual(SoftDeletable.objects.count(), 0) + self.assertEqual(SoftDeletable.all_objects.count(), 1) + + def test_instance_cannot_be_fully_deleted_via_queryset(self): + SoftDeletable.objects.create(name='a') + + SoftDeletable.objects.all().delete() + + self.assertEqual(SoftDeletable.objects.count(), 0) + self.assertEqual(SoftDeletable.all_objects.count(), 1)