├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── requirements.txt ├── runtests.py ├── setup.py ├── softdelete ├── __init__.py ├── managers.py └── models.py ├── test.sh └── tests ├── __init__.py ├── models.py ├── settings.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info/ 3 | .coverage 4 | coverage_annotate/ 5 | dist/ 6 | build/ 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 2.7 5 | - 3.4 6 | - 3.5 7 | - 3.6 8 | 9 | services: postgresql 10 | 11 | env: 12 | - DJANGO=1.11 TEST_DATABASE=sqlite3 13 | - DJANGO=1.10 TEST_DATABASE=sqlite3 14 | - DJANGO=1.9 TEST_DATABASE=sqlite3 15 | - DJANGO=1.8 TEST_DATABASE=sqlite3 16 | - DJANGO=1.11 TEST_DATABASE=postgres 17 | - DJANGO=1.10 TEST_DATABASE=postgres 18 | - DJANGO=1.9 TEST_DATABASE=postgres 19 | - DJANGO=1.8 TEST_DATABASE=postgres 20 | 21 | before_install: 22 | - export DJANGO_SETTINGS_MODULE=tests.settings 23 | - export PYTHONPATH=$HOME/builds/Basask/softdelete 24 | - export PIP_USE_MIRRORS=true 25 | 26 | install: 27 | - pip install -r requirements.txt 28 | - pip install django==$DJANGO --quiet 29 | - pip install psycopg2 --quiet 30 | 31 | before_script: 32 | - psql -c "CREATE DATABASE travisci;" -U postgres 33 | 34 | script: 35 | - python runtests.py 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Humantech Knowledge Management 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include requirements.txt 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django Soft Delete 2 | ============ 3 | 4 | [![Build Status](https://travis-ci.org/collabo-br/django-soft-delete.svg?branch=master)](https://travis-ci.org/collabo-br/django-soft-delete) 5 | [![codecov](https://codecov.io/gh/collabo-br/django-soft-delete/branch/master/graph/badge.svg)](https://codecov.io/gh/collabo-br/django-soft-delete) 6 | 7 | 8 | 9 | Django Soft Delete gives Django models the ability to soft delete(logical delete). it also gives the ability to restore or undelete soft-deleted instances. 10 | 11 | Basic usage 12 | ============ 13 | 14 | 1. Clone this repo and then ``$pip install django-soft-delete`` 15 | 1. Add softdelete model to INSTALLED_APPS 16 | 1. Inherit all models you want to have this functionality from softdelete.models.SoftDeleteModel 17 | 18 | ```bash 19 | 20 | >>> MyModel.objects.create(name='Anakin') 21 | >>> MyModel.objects.create(name='Luke') 22 | >>> MyModel.objects.create(name='Yoda') 23 | 24 | >>> luke = MyModel.objecs.filter(name='Luke') 25 | >>> MyModel.objecs.filter(name='Luke').delete() 26 | 27 | >>> MyModel.objects.count() 28 | 2 29 | 30 | >>> MyModel.raw_objects.count() 31 | 3 32 | 33 | >>> MyModel.objects.get(id=luke.id).undelete() 34 | >>> MyModel.objects.count() 35 | 3 36 | 37 | ``` 38 | 39 | Samples 40 | ============ 41 | 42 | ```python 43 | from softdelete.models import SoftDeleteModel 44 | 45 | 46 | class MyModel(SoftDeleteModel): 47 | name = models.CharField(max_length=30) 48 | ``` 49 | 50 | You can also use the SoftDelete django manager to extends your custom manager funcionalities. Do it like so: 51 | 52 | ```python 53 | #my_model_manager.py 54 | from softdelete.managers import SoftDeleteManager 55 | 56 | 57 | class MyModelManager(SoftDeleteManager): 58 | 59 | def create_john_smith(self): 60 | self.model.objects.create(name='Jonh Smith') 61 | 62 | 63 | #my_model.py 64 | from django.db import models 65 | from my_model_manager import MyModelManager 66 | 67 | 68 | class MyModel(SoftDeleteModel): 69 | name = models.CharField(max_length=30) 70 | 71 | objects = models.Manager() 72 | my_manager = MyModelManager() 73 | 74 | ``` 75 | 76 | It's possible to have access to delete instances through an alternative manager `` raw_objects`` 77 | 78 | ```python 79 | for inst in MyModel.raw_objects.all(): 80 | print inst.name 81 | ``` 82 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | coverage>=4.0.3 2 | Django>=1.9 3 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | if __name__ == "__main__": 10 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 11 | django.setup() 12 | TestRunner = get_runner(settings) 13 | test_runner = TestRunner() 14 | failures = test_runner.run_tests(["tests"]) 15 | sys.exit(bool(failures)) 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | 3 | from os import path 4 | from setuptools import find_packages 5 | from setuptools import setup 6 | 7 | 8 | def read(*parts): 9 | filename = path.join(path.dirname(__file__), *parts) 10 | with codecs.open(filename, encoding="utf-8") as fp: 11 | return fp.read() 12 | 13 | install_requires = open('requirements.txt').read().split('\n') 14 | 15 | setup( 16 | author="Collabo Software Ltda", 17 | author_email="thomas@collabo.com.br", 18 | description="Abstraction to logical/soft delete in django models", 19 | name="soft-delete", 20 | long_description=read("README.md"), 21 | long_description_content_type='text/markdown', 22 | version="0.2.2", 23 | url="https://www.collabo.com.br/", 24 | license="MIT", 25 | packages=find_packages(exclude=("tests",)), 26 | install_requires=install_requires, 27 | package_data={ 28 | "models": [] 29 | }, 30 | classifiers=[ 31 | "Development Status :: 4 - Beta", 32 | "Environment :: Web Environment", 33 | "Framework :: Django", 34 | "Intended Audience :: Developers", 35 | "License :: OSI Approved :: MIT License", 36 | "Operating System :: OS Independent", 37 | "Programming Language :: Python", 38 | "Programming Language :: Python :: 2", 39 | "Programming Language :: Python :: 3", 40 | "Topic :: Software Development :: Libraries :: Python Modules", 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /softdelete/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Date : 2015-10-29 15:50:53 3 | # @Author : Rafael Fernandes (basask@collabo.com.br) 4 | # @Link : http://www.collabo.com.br/ 5 | 6 | from __future__ import unicode_literals 7 | -------------------------------------------------------------------------------- /softdelete/managers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Date : 2015-10-29 15:49:53 3 | # @Author : Rafael Fernandes (basask@collabo.com.br) 4 | # @Link : http://www.collabo.com.br/ 5 | 6 | from __future__ import unicode_literals 7 | 8 | from django.db import models 9 | from django.db.models.query import QuerySet 10 | 11 | 12 | class SoftDeleteQueryMixin(object): 13 | def delete(self): 14 | for model_instance in self.all(): 15 | model_instance.delete() 16 | 17 | def undelete(self): 18 | for model_instance in self.raw_all(): 19 | model_instance.undelete() 20 | 21 | 22 | class SoftDeleteQuerySet(SoftDeleteQueryMixin, QuerySet): 23 | pass 24 | 25 | 26 | class SoftDeleteManager(SoftDeleteQueryMixin, models.Manager): 27 | 28 | def get_raw_queryset(self): 29 | return super(SoftDeleteManager, self).get_queryset() if self.model else None 30 | 31 | def get_queryset(self): 32 | if self.model: 33 | query_set = SoftDeleteQuerySet(self.model, using=self._db) 34 | return query_set.exclude(deleted_at__isnull=False) 35 | 36 | def get(self, *args, **kwargs): 37 | return self.get_raw_queryset().get(*args, **kwargs) 38 | 39 | def raw_all(self, *args, **kwargs): 40 | return self.get_raw_queryset().all(*args, **kwargs) 41 | -------------------------------------------------------------------------------- /softdelete/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Date : 2015-10-29 15:47:16 3 | # @Author : Rafael Fernandes (basask@collabo.com.br) 4 | # @Link : http://www.collabo.com.br/ 5 | 6 | from __future__ import unicode_literals 7 | 8 | from django.db import models 9 | from django.utils import timezone 10 | 11 | from softdelete.managers import SoftDeleteManager 12 | 13 | 14 | _call = lambda inst, method: hasattr(inst, method) and getattr(inst, method)() 15 | 16 | 17 | class SoftDeleteModel(models.Model): 18 | 19 | objects = SoftDeleteManager() 20 | raw_objects = models.Manager() 21 | 22 | deleted_at = models.DateTimeField(null=True, blank=True) 23 | 24 | class Meta(object): 25 | abstract = True 26 | 27 | 28 | def is_deleted(self): 29 | return self.deleted_at is not None 30 | 31 | def delete(self, *args, **kwargs): 32 | self.cascade_delete() 33 | 34 | def undelete(self): 35 | self.cascade_undelete() 36 | 37 | def soft_delete(self): 38 | self.deleted_at = timezone.now() 39 | self.save() 40 | 41 | def soft_undelete(self): 42 | self.deleted_at = None 43 | self.save() 44 | 45 | def hard_delete(self): 46 | return super(SoftDeleteModel, self).delete() 47 | 48 | def cascade_delete(self): 49 | self.chain_action('delete') 50 | self.soft_delete() 51 | 52 | def cascade_undelete(self): 53 | self.chain_action('undelete') 54 | self.soft_undelete() 55 | 56 | def get_all_related_objects(self): 57 | return ( 58 | f for f in self._meta.get_fields() 59 | if (f.one_to_many or f.one_to_one) 60 | and f.auto_created and not f.concrete 61 | ) 62 | 63 | def chain_action(self, method_name): 64 | for relation in self.get_all_related_objects(): 65 | accessor_name = relation.get_accessor_name() 66 | acessor = getattr(self, accessor_name, None) 67 | if acessor: 68 | if issubclass(acessor.__class__, (SoftDeleteModel, SoftDeleteManager,)): 69 | _call(acessor, method_name) 70 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | coverage run --source='softdelete' runtests.py 4 | coverage annotate -d coverage_annotate 5 | coverage report 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catallog/django-soft-delete/6671acbbadd8a99f4dabf803593e0b994cd4cc10/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Date : 2015-11-03 14:24:10 3 | # @Author : Rafael Fernandes (basask@collabo.com.br) 4 | # @Link : http://www.collabo.com.br/ 5 | 6 | from __future__ import unicode_literals 7 | 8 | from softdelete.models import SoftDeleteModel 9 | from django.db import models 10 | 11 | 12 | class NameMixin(models.Model): 13 | name = models.CharField(max_length=30) 14 | class Meta: 15 | abstract = True 16 | 17 | class TestModel(NameMixin, SoftDeleteModel): 18 | pass 19 | 20 | 21 | class TestRegularModel(NameMixin): 22 | pass 23 | 24 | 25 | class TestOneToMany(NameMixin, SoftDeleteModel): 26 | ref = models.OneToOneField(TestModel, on_delete=models.CASCADE, null=False, blank=False) 27 | 28 | 29 | class TestManyToMany(NameMixin, SoftDeleteModel): 30 | rigth = models.ForeignKey(TestModel, on_delete=models.CASCADE, related_name='rigth', null=False, blank=False) 31 | left = models.ForeignKey(TestModel, on_delete=models.CASCADE, related_name='left', null=False, blank=False) 32 | 33 | 34 | class TestNoSubclassReference(NameMixin): 35 | ref = models.OneToOneField(TestModel, on_delete=models.CASCADE, null=False, blank=False) 36 | 37 | 38 | class TestMixedM2MModel(NameMixin, SoftDeleteModel): 39 | regular = models.ForeignKey(TestRegularModel, on_delete=models.CASCADE, null=False, blank=False) 40 | soft = models.ForeignKey(TestModel, on_delete=models.CASCADE, null=False, blank=False) 41 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Date : 2015-11-03 14:26:37 3 | # @Author : Rafael Fernandes (basask@collabo.com.br) 4 | # @Link : http://www.collabo.com.br/ 5 | 6 | from __future__ import ( 7 | print_function, 8 | unicode_literals 9 | ) 10 | 11 | import os 12 | 13 | 14 | SECRET_KEY = 'nosecret' 15 | 16 | INSTALLED_APPS = [ 17 | "tests", 18 | ] 19 | 20 | if 'TRAVIS' in os.environ: 21 | database = os.environ.get('TEST_DATABASE') 22 | print("Testing in: {}".format(database)) 23 | if database == 'postgres': 24 | DATABASES = { 25 | 'default': { 26 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 27 | 'NAME': 'travisci', 28 | 'USER': 'postgres', 29 | 'PASSWORD': '', 30 | 'HOST': 'localhost', 31 | 'PORT': '', 32 | } 33 | } 34 | if database == 'sqlite3': 35 | DATABASES = { 36 | 'default': { 37 | 'ENGINE': 'django.db.backends.sqlite3', 38 | 'NAME': 'database.db', 39 | 'USER': '', 40 | 'PASSWORD': '', 41 | 'HOST': '', 42 | 'PORT': '', 43 | } 44 | } 45 | 46 | else: 47 | DATABASES = { 48 | 'default': { 49 | 'ENGINE': 'django.db.backends.sqlite3', 50 | 'NAME': 'database.db', 51 | 'USER': '', 52 | 'PASSWORD': '', 53 | 'HOST': '', 54 | 'PORT': '', 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Date : 2015-11-03 08:49:20 3 | # @Author : Rafael Fernandes (basask@collabo.com.br) 4 | # @Link : http://www.collabo.com.br/ 5 | 6 | from __future__ import unicode_literals 7 | 8 | from django.db import models 9 | from django.test import TestCase 10 | from .models import TestModel 11 | from .models import TestOneToMany 12 | from .models import TestManyToMany 13 | from .models import TestNoSubclassReference 14 | from .models import TestRegularModel 15 | from .models import TestMixedM2MModel 16 | 17 | 18 | class SoftDeleteTestCase(TestCase): 19 | 20 | def setUp(self): 21 | soft_a = TestModel.objects.create(name='Name_A') 22 | soft_b = TestModel.objects.create(name='Name_B') 23 | soft_c = TestModel.objects.create(name='Name_C') 24 | soft_d = TestModel.objects.create(name='Name_D') 25 | soft_e = TestModel.objects.create(name='Name_E') 26 | 27 | reg_a = TestRegularModel.objects.create(name='Name_A') 28 | reg_b = TestRegularModel.objects.create(name='Name_B') 29 | reg_c = TestRegularModel.objects.create(name='Name_C') 30 | reg_d = TestRegularModel.objects.create(name='Name_D') 31 | reg_e = TestRegularModel.objects.create(name='Name_E') 32 | 33 | TestOneToMany.objects.create(name='O2M_A', ref=soft_a) 34 | TestOneToMany.objects.create(name='O2M_B', ref=soft_b) 35 | TestOneToMany.objects.create(name='O2M_C', ref=soft_c) 36 | TestOneToMany.objects.create(name='O2M_D', ref=soft_d) 37 | TestOneToMany.objects.create(name='O2M_E', ref=soft_e) 38 | 39 | TestNoSubclassReference.objects.create(name='O2M_A', ref=soft_a) 40 | TestNoSubclassReference.objects.create(name='O2M_B', ref=soft_b) 41 | TestNoSubclassReference.objects.create(name='O2M_C', ref=soft_c) 42 | TestNoSubclassReference.objects.create(name='O2M_D', ref=soft_d) 43 | TestNoSubclassReference.objects.create(name='O2M_E', ref=soft_e) 44 | 45 | TestManyToMany.objects.create(name='M2M_AB', rigth=soft_a, left=soft_b) 46 | TestManyToMany.objects.create(name='M2M_AC', rigth=soft_a, left=soft_c) 47 | TestManyToMany.objects.create(name='M2M_BA', rigth=soft_b, left=soft_a) 48 | 49 | TestManyToMany.objects.create(name='M2M_DB', rigth=soft_d, left=soft_e) 50 | TestManyToMany.objects.create(name='M2M_ED', rigth=soft_e, left=soft_d) 51 | 52 | TestMixedM2MModel.objects.create(name='MX2M_AA', regular=reg_a, soft=soft_a) 53 | TestMixedM2MModel.objects.create(name='MX2M_BB', regular=reg_b, soft=soft_b) 54 | TestMixedM2MModel.objects.create(name='MX2M_CC', regular=reg_c, soft=soft_c) 55 | TestMixedM2MModel.objects.create(name='MX2M_DD', regular=reg_d, soft=soft_d) 56 | TestMixedM2MModel.objects.create(name='MX2M_EE', regular=reg_e, soft=soft_e) 57 | 58 | TestMixedM2MModel.objects.create(name='MX2M_AB', regular=reg_a, soft=soft_b) 59 | TestMixedM2MModel.objects.create(name='MX2M_AC', regular=reg_a, soft=soft_c) 60 | 61 | 62 | def _assert_count(self, soft, real=None): 63 | real = real or soft 64 | self.assertEqual(TestModel.objects.count(), soft) 65 | self.assertEqual(TestModel.raw_objects.count(), real) 66 | 67 | def test_insert_quantity_consistency(self): 68 | self._assert_count(5) 69 | self.assertEqual( TestOneToMany.objects.count(), 5) 70 | self.assertEqual( TestManyToMany.objects.count(), 5) 71 | self.assertEqual( TestNoSubclassReference.objects.count(), 5) 72 | 73 | TestModel.objects.create(name='Name_F') 74 | self._assert_count(6) 75 | 76 | 77 | def test_insert_mixed_manager(self): 78 | self._assert_count(5) 79 | 80 | TestModel.objects.create(name='Name_F') 81 | self._assert_count(6) 82 | 83 | TestModel.raw_objects.create(name='Name_G') 84 | self._assert_count(7) 85 | 86 | def test_model_logical_deletion(self): 87 | mod = TestModel.objects.filter(name='Name_A').last() 88 | mod.delete() 89 | self._assert_count(4, 5) 90 | 91 | def test_model_hard_deletion(self): 92 | mod = TestModel.raw_objects.filter(name='Name_A').last() 93 | mod.hard_delete() 94 | self._assert_count(4) 95 | 96 | def test_manager_softdelete(self): 97 | TestModel.objects.filter(name='Name_B').delete() 98 | self._assert_count(4, 5) 99 | 100 | TestModel.objects.last().delete() 101 | self._assert_count(3, 5) 102 | 103 | TestModel.objects.first().delete() 104 | self._assert_count(2, 5) 105 | 106 | def test_manager_harddeletion(self): 107 | TestModel.objects.last().hard_delete() 108 | self._assert_count(4) 109 | 110 | def test_raw_all(self): 111 | 112 | all = TestModel.objects.raw_all() 113 | self.assertEqual(len(all), 5) 114 | 115 | TestModel.objects.last().delete() 116 | all = TestModel.objects.raw_all() 117 | self.assertEqual(len(all), 5) 118 | 119 | def test_undelete(self): 120 | self._assert_count(5) 121 | 122 | TestModel.objects.filter(name='Name_A').last().delete() 123 | self._assert_count(4, 5) 124 | 125 | mod = TestModel.objects.get(name='Name_A') 126 | 127 | self.assertTrue(mod.is_deleted()) 128 | self.assertTrue(issubclass(mod.__class__, TestModel)) 129 | self._assert_count(4, 5) 130 | 131 | mod.undelete() 132 | self.assertFalse(mod.is_deleted()) 133 | self._assert_count(5) 134 | 135 | def test_cascade_delete(self): 136 | 137 | self.assertEqual(TestManyToMany.objects.count(), 5) 138 | self.assertEqual(TestOneToMany.objects.count(), 5) 139 | 140 | TestModel.objects.filter(name='Name_A').delete() 141 | self._assert_count(4, 5) 142 | 143 | self.assertEqual(TestNoSubclassReference.objects.count(), 5) 144 | self.assertEqual(TestOneToMany.objects.count(), 4) 145 | self.assertEqual(TestManyToMany.objects.count(), 2) 146 | 147 | TestModel.objects.filter(name='Name_E').delete() 148 | 149 | self.assertEqual(TestNoSubclassReference.objects.count(), 5) 150 | self.assertEqual(TestOneToMany.objects.count(), 3) 151 | self.assertEqual(TestManyToMany.objects.count(), 0) 152 | 153 | def test_cascade_undelete(self): 154 | 155 | self._assert_count(5) 156 | 157 | TestModel.objects.filter(name='Name_E').delete() 158 | self._assert_count(4, 5) 159 | self.assertEqual(TestNoSubclassReference.objects.count(), 5) 160 | self.assertEqual(TestOneToMany.objects.count(), 4) 161 | self.assertEqual(TestManyToMany.objects.count(), 3) 162 | 163 | TestModel.objects.get(name='Name_E').undelete() 164 | self._assert_count(5) 165 | self.assertEqual(TestNoSubclassReference.objects.count(), 5) 166 | self.assertEqual(TestOneToMany.objects.count(), 5) 167 | self.assertEqual(TestManyToMany.objects.count(), 5) 168 | 169 | def test_cascade_delete_regular_preserve(self): 170 | 171 | self.assertEqual(TestMixedM2MModel.objects.count(), 7) 172 | self.assertEqual(TestRegularModel.objects.count(), 5) 173 | 174 | TestModel.objects.filter(name='Name_A').delete() 175 | 176 | self.assertEqual(TestMixedM2MModel.objects.count(), 6) 177 | self.assertEqual(TestRegularModel.objects.count(), 5) 178 | 179 | TestModel.objects.filter(name='Name_C').delete() 180 | 181 | self.assertEqual(TestMixedM2MModel.objects.count(), 4) 182 | self.assertEqual(TestRegularModel.objects.count(), 5) 183 | 184 | TestModel.objects.get(name='Name_A').undelete() 185 | TestModel.objects.get(name='Name_C').undelete() 186 | 187 | self.assertEqual(TestMixedM2MModel.objects.count(), 7) 188 | self.assertEqual(TestRegularModel.objects.count(), 5) 189 | --------------------------------------------------------------------------------