├── softdelete ├── __init__.py ├── tests │ ├── __init__.py │ ├── constanats.py │ ├── test_views.py │ └── test_sd.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── south_migrations │ ├── __init__.py │ ├── 0003_auto__chg_field_softdeleterecord_object_id__chg_field_changeset_object.py │ ├── 0001_initial.py │ └── 0002_auto__del_recordset__del_unique_recordset_changeset_content_type_objec.py ├── test_softdelete_app │ ├── __init__.py │ ├── exceptions.py │ └── models.py ├── templates │ ├── softdelete │ │ ├── base.html │ │ ├── changeset_detail.html │ │ ├── changeset_form.html │ │ ├── changeset_list.html │ │ └── stubs │ │ │ └── changeset_detail.html │ ├── admin │ │ └── softdelete │ │ │ ├── changeset │ │ │ └── change_form.html │ │ │ ├── softdeleterecord │ │ │ └── change_form.html │ │ │ └── change_form.html │ └── base.html ├── signals.py ├── forms.py ├── admin │ ├── __init__.py │ ├── forms.py │ └── admin.py ├── urls.py ├── settings.py ├── views.py └── models.py ├── MANIFEST.in ├── .travis.yml ├── .gitignore ├── pyproject.toml ├── LICENSE ├── README.md └── tox.ini /softdelete/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /softdelete/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /softdelete/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /softdelete/south_migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /softdelete/test_softdelete_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | recursive-include softdelete *.html *.py 3 | -------------------------------------------------------------------------------- /softdelete/templates/softdelete/base.html: -------------------------------------------------------------------------------- 1 | {%extends "base.html"%} 2 | -------------------------------------------------------------------------------- /softdelete/test_softdelete_app/exceptions.py: -------------------------------------------------------------------------------- 1 | class ModelDeletionException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /softdelete/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | pre_soft_delete = Signal() 4 | post_soft_delete = Signal() 5 | pre_undelete = Signal() 6 | post_undelete = Signal() 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | sudo: false 3 | language: python 4 | python: 5 | - "2.7" 6 | - "3.4" 7 | - "3.5" 8 | - "3.6" 9 | - "3.7" 10 | install: pip install tox-travis 11 | script: tox 12 | -------------------------------------------------------------------------------- /softdelete/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import * 2 | from softdelete.models import * 3 | import logging 4 | 5 | 6 | class ChangeSetForm(ModelForm): 7 | class Meta: 8 | model = ChangeSet 9 | exclude = ('content_type', 'object_id',) 10 | -------------------------------------------------------------------------------- /softdelete/templates/admin/softdelete/changeset/change_form.html: -------------------------------------------------------------------------------- 1 | {%extends "admin/change_form.html" %} 2 | 3 | {%block after_related_objects%} 4 | {%if original.pk%} 5 | 6 | {%endif%} 7 | {%endblock%} 8 | -------------------------------------------------------------------------------- /softdelete/templates/admin/softdelete/softdeleterecord/change_form.html: -------------------------------------------------------------------------------- 1 | {%extends "admin/change_form.html" %} 2 | 3 | {%block after_related_objects%} 4 | {%if original.pk%} 5 | 6 | {%endif%} 7 | {%endblock%} 8 | -------------------------------------------------------------------------------- /softdelete/templates/admin/softdelete/change_form.html: -------------------------------------------------------------------------------- 1 | {%extends "admin/change_form.html" %} 2 | 3 | {%block after_related_objects%} 4 | {%if original.pk and original.deleted_flag%} 5 | 6 | {%endif%} 7 | {%endblock%} 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *~ 4 | .installed.cfg 5 | bin 6 | develop-eggs 7 | dist 8 | downloads 9 | eggs 10 | parts 11 | src/*.egg-info 12 | my_db 13 | 14 | # Not needed since this is only an app 15 | manage.py 16 | 17 | # WingIDE project files 18 | *.wpr 19 | *.wpu 20 | 21 | 22 | # TOX 23 | .tox 24 | .eggs 25 | 26 | -------------------------------------------------------------------------------- /softdelete/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from softdelete.admin.admin import * 2 | from softdelete.admin.forms import * 3 | 4 | __all__ = ['SoftDeleteObjectAdmin', 5 | 'SoftDeleteRecordAdmin', 6 | 'ChangeSetAdmin', 7 | 'SoftDeleteObjectInline', 8 | 'SoftDeleteObjectAdminForm', 9 | ] 10 | 11 | -------------------------------------------------------------------------------- /softdelete/templates/softdelete/changeset_detail.html: -------------------------------------------------------------------------------- 1 | {%extends "softdelete/base.html"%} 2 | 3 | {%block content%} 4 | {%include "softdelete/stubs/changeset_detail.html" with changeset=object%} 5 |
6 | 7 |
8 | {%endblock%} 9 | -------------------------------------------------------------------------------- /softdelete/templates/softdelete/changeset_form.html: -------------------------------------------------------------------------------- 1 | {%extends "softdelete/base.html"%} 2 | 3 | {%block content%} 4 | 5 |
6 | {%csrf_token%} 7 | 8 | {{form.as_table}} 9 | 10 | 11 | 12 | 13 |
14 |
15 | 16 | 17 | {%endblock%} 18 | -------------------------------------------------------------------------------- /softdelete/templates/softdelete/changeset_list.html: -------------------------------------------------------------------------------- 1 | {%extends "softdelete/base.html"%} 2 | 3 | {%block content%} 4 |

ChangeSets

5 | {%for object in object_list%} 6 | {%if forloop.first%} 7 |
8 | {%endif%} 9 | {%include "softdelete/stubs/changeset_detail.html" with changeset=object%} 10 | {%if forloop.last%} 11 |
12 | {%endif%} 13 | {%endfor%} 14 | 15 | {%endblock%} 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-softdelete" 3 | version = "0.11.4" 4 | description = "Soft delete support for Django ORM, with undelete." 5 | readme = {file = "README.txt", content-type = "text/markdown"} 6 | authors = [ 7 | {name = "Steve Coursen", email = "smcoursen@gmail.com"}, 8 | ] 9 | classifiers = [ 10 | "Environment :: Web Environment", 11 | "Framework :: Django", 12 | "License :: OSI Approved :: BSD License", 13 | ] 14 | 15 | [project.urls] 16 | Homepage = "https://github.com/scoursen/django-softdelete" 17 | -------------------------------------------------------------------------------- /softdelete/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | You should never see this 4 | 5 | 6 |

If you are seeing this, django-softdelete's base.html template is being 7 | found by the template renderer before your site's base.html. You should 8 | place the app that has the desired base.html earlier in the INSTALLED_APPS 9 | into settings.py. 10 |

You could also replace base.html in the 11 | <path-to-django-softdelete>/key/templates/base.html with other content. 12 | {%block content%} 13 | {%endblock%} 14 | 15 | 16 | -------------------------------------------------------------------------------- /softdelete/tests/constanats.py: -------------------------------------------------------------------------------- 1 | from softdelete.test_softdelete_app.models import TestModelTwoCascade, TestModelTwoDoNothing, TestModelTwoSetNull 2 | 3 | TEST_MODEL_ONE_COUNT = 2 4 | TEST_MODEL_TWO_TOTAL_COUNT = 12 # should be multiple of LCM(TEST_MODEL_ONE_COUNT,TEST_MODEL_TWO_COUNT), 5 | # here LCM is 6 and multiplier is 2 6 | 7 | TEST_MODEL_THREE_COUNT = TEST_MODEL_TWO_TOTAL_COUNT ** 2 8 | TEST_MODEL_TWO_LIST = [TestModelTwoCascade, 9 | TestModelTwoDoNothing, 10 | TestModelTwoSetNull] 11 | TEST_MODEL_TWO_CASCADE_COUNT = TEST_MODEL_TWO_DO_NOTHING_COUNT = TEST_MODEL_TWO_SET_NULL_COUNT = \ 12 | TEST_MODEL_TWO_TOTAL_COUNT // len(TEST_MODEL_TWO_LIST) 13 | -------------------------------------------------------------------------------- /softdelete/templates/softdelete/stubs/changeset_detail.html: -------------------------------------------------------------------------------- 1 |

2 | 3 | Changeset created at {{changeset.created_date}} 4 | 5 |

6 |
7 | This changeset contains the following modified models: 8 | {%for record in changeset.recordsets.all%} 9 | {%if forloop.first%} 10 | 23 | {%endif%} 24 | {%endfor%} 25 |
26 | -------------------------------------------------------------------------------- /softdelete/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, re_path 2 | from softdelete.views import * 3 | 4 | urlpatterns = [ 5 | re_path(r'^changeset/(?P\d+?)/undelete/$', 6 | ChangeSetUpdate.as_view(), 7 | name="softdelete.changeset.undelete"), 8 | re_path(r'^changeset/(?P\d+?)/$', 9 | ChangeSetDetail.as_view(), 10 | name="softdelete.changeset.view"), 11 | re_path(r'^changeset/$', 12 | ChangeSetList.as_view(), 13 | name="softdelete.changeset.list"), 14 | ] 15 | 16 | import sys 17 | if 'test' in sys.argv: 18 | import django 19 | from django.contrib import admin 20 | admin.autodiscover() 21 | 22 | if django.VERSION[0] >= 2: 23 | from django.urls import path 24 | urlpatterns.append(path('admin/', admin.site.urls)) 25 | urlpatterns.append(path('accounts/', include('django.contrib.auth.urls'))) 26 | else: 27 | urlpatterns.append(re_path(r'^admin/', include(admin.site.urls))) 28 | urlpatterns.append(re_path(r'^accounts/', include('django.contrib.auth.urls'))) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Redistribution and use in source and binary forms, with or without 2 | modification, are permitted provided that the following conditions 3 | are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright 6 | notice, this list of conditions and the following disclaimer. 7 | 2. Redistributions in binary form must reproduce the above copyright 8 | notice, this list of conditions and the following disclaimer in the 9 | documentation and/or other materials provided with the distribution. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 12 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 13 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 14 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 15 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 16 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 17 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 18 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 19 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 20 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 21 | -------------------------------------------------------------------------------- /softdelete/admin/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import * 2 | from softdelete.models import * 3 | from softdelete.forms import * 4 | import logging 5 | 6 | class SoftDeleteObjectAdminForm(ModelForm): 7 | deleted = BooleanField(required=False) 8 | 9 | class Meta: 10 | model = SoftDeleteObject 11 | exclude = ('deleted_at',) 12 | 13 | def __init__(self, *args, **kwargs): 14 | super(SoftDeleteObjectAdminForm, self).__init__(*args, **kwargs) 15 | instance = kwargs.get('instance') 16 | if instance: 17 | self.initial['deleted'] = instance.deleted 18 | 19 | def clean(self, *args, **kwargs): 20 | cleaned_data = super(SoftDeleteObjectAdminForm, self).clean(*args, **kwargs) 21 | if 'undelete' in self.data: 22 | self.instance.deleted = False 23 | cleaned_data['deleted'] = False 24 | return cleaned_data 25 | 26 | def save(self, commit=True, *args, **kwargs): 27 | model = super(SoftDeleteObjectAdminForm, self).save(commit=False, 28 | *args, **kwargs) 29 | model.deleted = self.cleaned_data['deleted'] 30 | if commit: 31 | model.save() 32 | return model 33 | 34 | class ChangeSetAdminForm(ChangeSetForm): 35 | pass 36 | 37 | class SoftDeleteRecordAdminForm(ModelForm): 38 | class Meta: 39 | model = SoftDeleteRecord 40 | readonly_fields = ('created', ) 41 | exclude = ('content_type', 'object_id', 'changeset') 42 | 43 | -------------------------------------------------------------------------------- /softdelete/settings.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import django 4 | 5 | BASE_DIR = os.path.dirname(__file__) 6 | 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | 'NAME': 'my_db', 11 | } 12 | } 13 | 14 | INSTALLED_APPS = [ 15 | 'softdelete', 16 | 'django.contrib.auth', 17 | 'django.contrib.contenttypes', 18 | 'django.contrib.sessions', 19 | 'django.contrib.admin', 20 | 'django.contrib.messages', 21 | ] 22 | 23 | if django.VERSION[0] >= 2: 24 | MIDDLEWARE = [ 25 | 'django.contrib.sessions.middleware.SessionMiddleware', 26 | 'django.middleware.common.CommonMiddleware', 27 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 28 | 'django.contrib.messages.middleware.MessageMiddleware', 29 | ] 30 | SILENCED_SYSTEM_CHECKS = ( 31 | 'admin.E130', 32 | ) 33 | else: 34 | MIDDLEWARE_CLASSES= [ 35 | 'django.contrib.sessions.middleware.SessionMiddleware', 36 | 'django.middleware.common.CommonMiddleware', 37 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 38 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 39 | 'django.contrib.messages.middleware.MessageMiddleware', 40 | ] 41 | 42 | TEMPLATES = [ 43 | { 44 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 45 | 'DIRS': [ 46 | os.path.join(BASE_DIR, 'tests/templates') 47 | ], 48 | 'OPTIONS': { 49 | 'debug': True, 50 | 'loaders': ( 51 | 'django.template.loaders.filesystem.Loader', 52 | 'django.template.loaders.app_directories.Loader', 53 | ), 54 | 'context_processors': ( 55 | 'django.contrib.messages.context_processors.messages', 56 | 'django.contrib.auth.context_processors.auth', 57 | 'django.template.context_processors.request' 58 | ) 59 | } 60 | }, 61 | ] 62 | 63 | 64 | DOMAIN = 'http://testserver' 65 | ROOT_URLCONF = 'softdelete.urls' 66 | SECRET_KEY = "dummy" 67 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 68 | 69 | if 'test' in sys.argv: 70 | INSTALLED_APPS.append("softdelete.test_softdelete_app") 71 | -------------------------------------------------------------------------------- /softdelete/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.http import HttpResponse, Http404, HttpResponseRedirect 4 | from django.conf import settings 5 | from django.shortcuts import get_object_or_404, redirect 6 | from django.core import serializers 7 | from django.contrib import auth 8 | from django.contrib.auth.decorators import permission_required 9 | from django.forms.models import inlineformset_factory 10 | from django.views.generic import * 11 | from django.views.generic.base import TemplateResponseMixin, View 12 | from django.views.decorators.csrf import csrf_exempt 13 | from django.utils.decorators import method_decorator 14 | from django.template import RequestContext 15 | try: 16 | from django.core.urlresolvers import reverse 17 | except ImportError: 18 | from django.urls import reverse 19 | 20 | from softdelete.forms import * 21 | from softdelete.models import * 22 | 23 | 24 | class ProtectedView(object): 25 | @method_decorator(permission_required('softdelete.can_undelete')) 26 | def dispatch(self, *args, **kwargs): 27 | return super(ProtectedView, self).dispatch(*args, **kwargs) 28 | 29 | def get_context_data(self, **kwargs): 30 | context = super(ProtectedView, self).get_context_data(**kwargs) 31 | context['request'] = self.request 32 | return context 33 | 34 | class ChangeSetList(ProtectedView, ListView): 35 | model = ChangeSet 36 | 37 | def get_query_set(self): 38 | return self.model.objects.all() 39 | 40 | def get_queryset(self): 41 | return self.model.objects.all() 42 | 43 | class ChangeSetDetail(ProtectedView, DetailView): 44 | model = ChangeSet 45 | 46 | def get_object(self): 47 | return get_object_or_404(ChangeSet, pk=self.kwargs['changeset_pk']) 48 | 49 | class ChangeSetUpdate(ProtectedView, UpdateView): 50 | model = ChangeSet 51 | form_class = ChangeSetForm 52 | 53 | def get_object(self): 54 | return get_object_or_404(ChangeSet, pk=self.kwargs['changeset_pk']) 55 | 56 | def get_success_url(self): 57 | return reverse('softdelete.changeset.list') 58 | 59 | @method_decorator(csrf_exempt) 60 | def dispatch(self, *args, **kwargs): 61 | return super(ChangeSetUpdate, self).dispatch(*args, **kwargs) 62 | 63 | def post(self, request, *args, **kwargs): 64 | if request.POST.get('action') != 'Undelete': 65 | return HttpResponseRedirect(reverse('softdelete.changeset.view', 66 | args=(kwargs['changeset_pk'],))) 67 | self.get_object().undelete() 68 | return HttpResponseRedirect(self.get_success_url()) 69 | -------------------------------------------------------------------------------- /softdelete/south_migrations/0003_auto__chg_field_softdeleterecord_object_id__chg_field_changeset_object.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | 12 | # Changing field 'SoftDeleteRecord.object_id' 13 | db.alter_column(u'softdelete_softdeleterecord', 'object_id', self.gf('django.db.models.fields.CharField')(max_length=100)) 14 | 15 | # Changing field 'ChangeSet.object_id' 16 | db.alter_column(u'softdelete_changeset', 'object_id', self.gf('django.db.models.fields.CharField')(max_length=100)) 17 | 18 | def backwards(self, orm): 19 | 20 | # Changing field 'SoftDeleteRecord.object_id' 21 | db.alter_column(u'softdelete_softdeleterecord', 'object_id', self.gf('django.db.models.fields.PositiveIntegerField')()) 22 | 23 | # Changing field 'ChangeSet.object_id' 24 | db.alter_column(u'softdelete_changeset', 'object_id', self.gf('django.db.models.fields.PositiveIntegerField')()) 25 | 26 | models = { 27 | u'contenttypes.contenttype': { 28 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 29 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 30 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 31 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 32 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 33 | }, 34 | u'softdelete.changeset': { 35 | 'Meta': {'object_name': 'ChangeSet'}, 36 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), 37 | 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow'}), 38 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 39 | 'object_id': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 40 | }, 41 | u'softdelete.softdeleterecord': { 42 | 'Meta': {'unique_together': "(('changeset', 'content_type', 'object_id'),)", 'object_name': 'SoftDeleteRecord'}, 43 | 'changeset': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'soft_delete_records'", 'to': u"orm['softdelete.ChangeSet']"}), 44 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), 45 | 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow'}), 46 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 47 | 'object_id': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 48 | } 49 | } 50 | 51 | complete_apps = ['softdelete'] -------------------------------------------------------------------------------- /softdelete/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-09-24 13:11 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ("contenttypes", "0002_remove_content_type_name"), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="ChangeSet", 19 | fields=[ 20 | ( 21 | "id", 22 | models.BigAutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ( 30 | "created_date", 31 | models.DateTimeField(default=django.utils.timezone.now), 32 | ), 33 | ("object_id", models.CharField(max_length=100)), 34 | ( 35 | "content_type", 36 | models.ForeignKey( 37 | on_delete=django.db.models.deletion.CASCADE, 38 | to="contenttypes.contenttype", 39 | ), 40 | ), 41 | ], 42 | ), 43 | migrations.CreateModel( 44 | name="SoftDeleteRecord", 45 | fields=[ 46 | ( 47 | "id", 48 | models.BigAutoField( 49 | auto_created=True, 50 | primary_key=True, 51 | serialize=False, 52 | verbose_name="ID", 53 | ), 54 | ), 55 | ( 56 | "created_date", 57 | models.DateTimeField(default=django.utils.timezone.now), 58 | ), 59 | ("object_id", models.CharField(max_length=100)), 60 | ( 61 | "changeset", 62 | models.ForeignKey( 63 | on_delete=django.db.models.deletion.CASCADE, 64 | related_name="soft_delete_records", 65 | to="softdelete.changeset", 66 | ), 67 | ), 68 | ( 69 | "content_type", 70 | models.ForeignKey( 71 | on_delete=django.db.models.deletion.CASCADE, 72 | to="contenttypes.contenttype", 73 | ), 74 | ), 75 | ], 76 | ), 77 | migrations.AddIndex( 78 | model_name="changeset", 79 | index=models.Index( 80 | fields=["content_type", "object_id"], 81 | name="softdelete__content_7eb5a8_idx", 82 | ), 83 | ), 84 | migrations.AddIndex( 85 | model_name="softdeleterecord", 86 | index=models.Index( 87 | fields=["content_type", "object_id"], 88 | name="softdelete__content_faa170_idx", 89 | ), 90 | ), 91 | migrations.AlterUniqueTogether( 92 | name="softdeleterecord", 93 | unique_together={("changeset", "content_type", "object_id")}, 94 | ), 95 | ] 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-softdelete [![Build Status](https://travis-ci.com/mark0978/django-softdelete.svg?branch=master)](https://travis-ci.com/mark0978/django-softdelete) 2 | 3 | Soft delete for Django ORM, with support for undelete. Supports Django 2.0+ 4 | 5 | This project provides undelete of soft-deleted objects, along with proper undeletion of related objects. 6 | 7 | Inspired by http://codespatter.com/2009/07/01/django-model-manager-soft-delete-how-to-customize-admin/ 8 | 9 | ## Requirements 10 | 11 | 12 | * Django 1.8+ 13 | * django.contrib.contenttypes 14 | 15 | ## Installation 16 | 17 | pip install django-softdelete 18 | 19 | ## Configuration 20 | 21 | There are simple templates files in `templates/`. You will need to add Django's 22 | egg loader to use the templates as is, that would look something like this: 23 | 24 | TEMPLATES = [ 25 | { 26 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 27 | 'DIRS': '/path/to/my/templates', 28 | 'OPTIONS': { 29 | 'loaders': ( 30 | 'django.template.loaders.filesystem.Loader', 31 | 'django.template.loaders.app_directories.Loader', 32 | ), 33 | } 34 | }, 35 | ] 36 | 37 | Add the project `softdelete` to your `INSTALLED_APPS` for 38 | through-the-web undelete support. 39 | 40 | INSTALLED_APPS = ( 41 | ... 42 | 'django.contrib.contenttypes', 43 | 'softdelete', 44 | ) 45 | 46 | Usage 47 | ===== 48 | - Run `django-admin migrate` 49 | - For the models that you want __soft delete__ to be implemented in, inherit from the `SoftDeleteObject` with `from softdelete.models import SoftDeleteObject`. Something like `MyCustomModel(SoftDeleteObject, models.Model)`. This will add an extra `deleted_at` field which will appear in the admin form after deleting/undeleting the object 50 | - If you have a custom manager also make sure to inherit from the `SoftDeleteManager`. 51 | - After that you can test it by __deleting__ and __undeleting__ objects from your models. Have fun undeleting :) 52 | 53 | Settings 54 | ======== 55 | 56 | |Name|Default| Description | 57 | |---|---|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 58 | |`SOFTDELETE_CASCADE_ALLOW_DELETE_ALL`|True| Setting to confirm if the logic for deleting related entities should fall back to deleting all model entities in the event of an exception being raised when calling delete | 59 | 60 | How It Works 61 | ============ 62 | 63 | Central to the ability to undelete a soft-deleted model is the concept of changesets. When you 64 | soft-delete an object, any objects referencing it via a ForeignKey, ManyToManyField, or OneToOneField will 65 | also be soft-deleted. This mimics the traditional CASCADE behavior of a SQL DELETE. 66 | 67 | When the soft-delete is performed, the system makes a ChangeSet object which tracks all affected objects of 68 | this delete request. Later, when an undelete is requested, this ChangeSet is referenced to do a cascading 69 | undelete. 70 | 71 | If you are undeleting an object that was part of a ChangeSet, that entire ChangeSet is undeleted. 72 | 73 | Once undeleted, the ChangeSet object is removed from the underlying database with a regular ("hard") delete. 74 | 75 | Warnings 76 | ===== 77 | 78 | When using cascade delete, the default behaviour when the call to delete a related object raises an exception is 79 | to fallback to deleting all the entities for that model class from the database. You can prevent this behaviour 80 | by using the `SOFTDELETE_CASCADE_ALLOW_DELETE_ALL` setting. Set this to `False` to prevent the behaviour. 81 | 82 | ## Testing 83 | 84 | 85 | Can be tested directly with the following command: 86 | 87 | django-admin.py test softdelete --settings="softdelete.settings" 88 | -------------------------------------------------------------------------------- /softdelete/south_migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | class Migration(SchemaMigration): 8 | 9 | def forwards(self, orm): 10 | 11 | # Adding model 'ChangeSet' 12 | db.create_table('softdelete_changeset', ( 13 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 14 | ('created_date', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.utcnow)), 15 | ('content_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'])), 16 | ('object_id', self.gf('django.db.models.fields.PositiveIntegerField')()), 17 | )) 18 | db.send_create_signal('softdelete', ['ChangeSet']) 19 | 20 | # Adding model 'RecordSet' 21 | db.create_table('softdelete_recordset', ( 22 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 23 | ('changeset', self.gf('django.db.models.fields.related.ForeignKey')(related_name='recordsets', to=orm['softdelete.ChangeSet'])), 24 | ('created_date', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.utcnow)), 25 | ('content_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'])), 26 | ('object_id', self.gf('django.db.models.fields.PositiveIntegerField')()), 27 | )) 28 | db.send_create_signal('softdelete', ['RecordSet']) 29 | 30 | # Adding unique constraint on 'RecordSet', fields ['changeset', 'content_type', 'object_id'] 31 | db.create_unique('softdelete_recordset', ['changeset_id', 'content_type_id', 'object_id']) 32 | 33 | 34 | def backwards(self, orm): 35 | 36 | # Removing unique constraint on 'RecordSet', fields ['changeset', 'content_type', 'object_id'] 37 | db.delete_unique('softdelete_recordset', ['changeset_id', 'content_type_id', 'object_id']) 38 | 39 | # Deleting model 'ChangeSet' 40 | db.delete_table('softdelete_changeset') 41 | 42 | # Deleting model 'RecordSet' 43 | db.delete_table('softdelete_recordset') 44 | 45 | 46 | models = { 47 | 'contenttypes.contenttype': { 48 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 49 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 50 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 51 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 52 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 53 | }, 54 | 'softdelete.changeset': { 55 | 'Meta': {'object_name': 'ChangeSet'}, 56 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 57 | 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow'}), 58 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 59 | 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) 60 | }, 61 | 'softdelete.recordset': { 62 | 'Meta': {'unique_together': "(('changeset', 'content_type', 'object_id'),)", 'object_name': 'RecordSet'}, 63 | 'changeset': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'recordsets'", 'to': "orm['softdelete.ChangeSet']"}), 64 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 65 | 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow'}), 66 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 67 | 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) 68 | } 69 | } 70 | 71 | complete_apps = ['softdelete'] 72 | -------------------------------------------------------------------------------- /softdelete/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.test import TestCase, Client 3 | from django.contrib.auth.models import User 4 | from django.db import models 5 | from softdelete.test_softdelete_app.models import TestModelOne, TestModelTwoCascade, TestModelThree 6 | from softdelete.models import * 7 | from softdelete.signals import * 8 | import logging 9 | try: 10 | from django.core.urlresolvers import reverse 11 | except ImportError: 12 | from django.urls import reverse 13 | 14 | 15 | class ViewBase(TestCase): 16 | def setUp(self): 17 | u, c = User.objects.get_or_create(username="undelete_test") 18 | u.is_active = True 19 | u.set_password("undelete_password") 20 | gr = create_group() 21 | if USE_SOFTDELETE_GROUP: 22 | gr = Group.objects.get(name="Softdelete User") 23 | u.groups.add(gr) 24 | u.save() 25 | gr.save() 26 | else: 27 | assign_permissions(u) 28 | u.save() 29 | self.tmo1 = TestModelOne.objects.create(extra_bool=True) 30 | self.tmo3 = TestModelThree.objects.create(extra_int=3) 31 | for x in range(10): 32 | TestModelTwoCascade.objects.create(extra_int=x, tmo=self.tmo1) 33 | self.tmo2 = TestModelOne.objects.create(extra_bool=False) 34 | for x in range(10): 35 | TestModelTwoCascade.objects.create(extra_int=x * x, tmo=self.tmo2) 36 | self.tmo2.delete() 37 | 38 | 39 | class ViewTest(ViewBase): 40 | def __init__(self, *args, **kwargs): 41 | settings.USE_SOFTDELETE_GROUP = kwargs.get('USE_SOFTDELETE_GROUP', False) 42 | if 'USE_SOFTDELETE_GROUP' in kwargs: 43 | del kwargs['USE_SOFTDELETE_GROUP'] 44 | super(ViewTest, self).__init__(*args, **kwargs) 45 | 46 | def setUp(self): 47 | super(ViewTest, self).setUp() 48 | self.client = Client() 49 | self.client.login(username="undelete_test", 50 | password="undelete_password") 51 | 52 | def test_authorization(self): 53 | rv = self.client.get(reverse("softdelete.changeset.list")) 54 | pk = ChangeSet.objects.latest('created_date').pk 55 | for view_name in [reverse("softdelete.changeset.list"), 56 | reverse("softdelete.changeset.view", args=(pk,)), 57 | reverse("softdelete.changeset.undelete", args=(pk,)),]: 58 | cli2 = Client() 59 | rv = cli2.get(view_name) 60 | # Make sure we redirected to a login page 61 | self.assertEquals(rv.status_code, 302) 62 | self.assertIn(reverse('login'), rv['Location']) 63 | # But don't try to render it. 64 | 65 | def test_undelete(self): 66 | self.cs_count = ChangeSet.objects.count() 67 | self.rs_count = SoftDeleteRecord.objects.count() 68 | self.t_count = TestModelThree.objects.count() 69 | self.tmo3.delete() 70 | self.assertEquals(self.t_count-1, TestModelThree.objects.count()) 71 | self.assertEquals(0, self.tmo3.tmos.count()) 72 | self.assertEquals(self.cs_count+1, ChangeSet.objects.count()) 73 | self.assertEquals(self.rs_count+1, SoftDeleteRecord.objects.count()) 74 | rv = self.client.get(reverse("softdelete.changeset.undelete", 75 | args=(ChangeSet.objects.latest("created_date").pk,))) 76 | self.assertEquals(rv.status_code,200) 77 | rv = self.client.post(reverse("softdelete.changeset.undelete", 78 | args=(ChangeSet.objects.latest("created_date").pk,)), 79 | {'action': 'Undelete'}) 80 | self.assertEquals(rv.status_code, 302) 81 | rv = self.client.get(rv['Location']) 82 | self.assertEquals(rv.status_code, 200) 83 | self.assertEquals(self.cs_count, ChangeSet.objects.count()) 84 | self.assertEquals(self.rs_count, SoftDeleteRecord.objects.count()) 85 | self.assertEquals(self.t_count, TestModelThree.objects.count()) 86 | self.assertEquals(0, self.tmo3.tmos.count()) 87 | 88 | 89 | class GroupViewTest(ViewTest): 90 | def __init__(self, *args, **kwargs): 91 | super(GroupViewTest, self).__init__(USE_SOFTDELETE_GROUP=True, *args, **kwargs) 92 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py34-E, 3 | py35-E, py35-F, py35-G, 4 | py36-E, py36-F, py36-G, 5 | py37-E, py37-F, py37-G, py37-H, py37-I, py37-J, 6 | py38-E, py38-F, py38-G, py38-H, py38-I, py38-J, py38-K, 7 | py39-E, py39-F, py39-G, py39-H, py39-I, py39-J, py39-K, 8 | py310-E, py310-F, py310-G, py310-H, py310-I, py310-J, py310-K 9 | skip_missing_interpreters=True 10 | 11 | [testenv] 12 | commands = django-admin test --settings softdelete.settings 13 | 14 | usedevelop = True 15 | deps = 16 | 17 | [testenv:py34-E] 18 | basepython = python3.4 19 | deps = {[testenv]deps} 20 | Django>=2.0,<2.1 21 | 22 | [testenv:py35-E] 23 | basepython = python3.5 24 | deps = {[testenv]deps} 25 | Django>=2.0,<2.1 26 | 27 | [testenv:py35-F] 28 | basepython = python3.5 29 | deps = {[testenv]deps} 30 | Django>=2.1,<2.2 31 | 32 | [testenv:py35-G] 33 | basepython = python3.5 34 | deps = {[testenv]deps} 35 | Django>=2.2,<2.3 36 | 37 | [testenv:py36-E] 38 | basepython = python3.6 39 | deps = {[testenv]deps} 40 | Django>=2.0,<2.1 41 | 42 | [testenv:py36-F] 43 | basepython = python3.6 44 | deps = {[testenv]deps} 45 | Django>=2.1,<2.2 46 | 47 | [testenv:py36-G] 48 | basepython = python3.6 49 | deps = {[testenv]deps} 50 | Django>=2.2,<2.3 51 | 52 | [testenv:py37-E] 53 | basepython = python3.7 54 | deps = {[testenv]deps} 55 | Django>=2.0,<2.1 56 | 57 | [testenv:py37-F] 58 | basepython = python3.7 59 | deps = {[testenv]deps} 60 | Django>=2.1,<2.2 61 | 62 | [testenv:py37-G] 63 | basepython = python3.7 64 | deps = {[testenv]deps} 65 | Django>=2.2,<2.3 66 | 67 | [testenv:py37-H] 68 | basepython = python3.7 69 | deps = {[testenv]deps} 70 | Django>=3.0,<3.1 71 | 72 | [testenv:py37-I] 73 | basepython = python3.7 74 | deps = {[testenv]deps} 75 | Django>=3.1,<3.2 76 | 77 | [testenv:py37-J] 78 | basepython = python3.7 79 | deps = {[testenv]deps} 80 | Django>=3.2,<4.0 81 | 82 | [testenv:py37-K] 83 | basepython = python3.7 84 | deps = {[testenv]deps} 85 | Django>=4.0,<4.1 86 | 87 | [testenv:py38-E] 88 | basepython = python3.8 89 | deps = {[testenv]deps} 90 | Django>=2.0,<2.1 91 | 92 | [testenv:py38-F] 93 | basepython = python3.8 94 | deps = {[testenv]deps} 95 | Django>=2.1,<2.2 96 | 97 | [testenv:py38-G] 98 | basepython = python3.8 99 | deps = {[testenv]deps} 100 | Django>=2.2,<2.3 101 | 102 | [testenv:py38-H] 103 | basepython = python3.8 104 | deps = {[testenv]deps} 105 | Django>=3.0,<3.1 106 | 107 | [testenv:py38-I] 108 | basepython = python3.8 109 | deps = {[testenv]deps} 110 | Django>=3.1,<3.2 111 | 112 | [testenv:py38-J] 113 | basepython = python3.8 114 | deps = {[testenv]deps} 115 | Django>=3.2,<4.0 116 | 117 | [testenv:py38-K] 118 | basepython = python3.8 119 | deps = {[testenv]deps} 120 | Django>=4.0,<4.1 121 | 122 | [testenv:py39-E] 123 | basepython = python3.9 124 | deps = {[testenv]deps} 125 | Django>=2.0,<2.1 126 | 127 | [testenv:py39-F] 128 | basepython = python3.9 129 | deps = {[testenv]deps} 130 | Django>=2.1,<2.2 131 | 132 | [testenv:py39-G] 133 | basepython = python3.9 134 | deps = {[testenv]deps} 135 | Django>=2.2,<2.3 136 | 137 | [testenv:py39-H] 138 | basepython = python3.9 139 | deps = {[testenv]deps} 140 | Django>=3.0,<3.1 141 | 142 | [testenv:py39-I] 143 | basepython = python3.9 144 | deps = {[testenv]deps} 145 | Django>=3.1,<3.2 146 | 147 | [testenv:py39-J] 148 | basepython = python3.9 149 | deps = {[testenv]deps} 150 | Django>=3.2,<4.0 151 | 152 | [testenv:py39-K] 153 | basepython = python3.9 154 | deps = {[testenv]deps} 155 | Django>=4.0,<4.1 156 | 157 | [testenv:py310-E] 158 | basepython = python3.10 159 | deps = {[testenv]deps} 160 | Django>=2.0,<2.1 161 | 162 | [testenv:py310-F] 163 | basepython = python3.10 164 | deps = {[testenv]deps} 165 | Django>=2.1,<2.2 166 | 167 | [testenv:py310-G] 168 | basepython = python3.10 169 | deps = {[testenv]deps} 170 | Django>=2.2,<2.3 171 | 172 | [testenv:py310-H] 173 | basepython = python3.10 174 | deps = {[testenv]deps} 175 | Django>=3.0,<3.1 176 | 177 | [testenv:py310-I] 178 | basepython = python3.10 179 | deps = {[testenv]deps} 180 | Django>=3.1,<3.2 181 | 182 | [testenv:py310-J] 183 | basepython = python3.10 184 | deps = {[testenv]deps} 185 | Django>=3.2,<4.0 186 | 187 | [testenv:py310-K] 188 | basepython = python3.10 189 | deps = {[testenv]deps} 190 | Django>=4.0,<4.1 191 | 192 | -------------------------------------------------------------------------------- /softdelete/test_softdelete_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib import admin 3 | from django.db.models import QuerySet 4 | 5 | from softdelete.models import * 6 | from softdelete.admin import * 7 | from softdelete.test_softdelete_app.exceptions import ModelDeletionException 8 | from django.contrib.contenttypes.models import ContentType 9 | try: 10 | from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey 11 | except ImportError: 12 | from django.contrib.contenttypes.generic import GenericRelation, GenericForeignKey 13 | 14 | 15 | class TestModelOne(SoftDeleteObject): 16 | extra_bool = models.BooleanField(default=False) 17 | 18 | class TestGenericForeignKey(SoftDeleteObject): 19 | content_type = models.ForeignKey(ContentType, on_delete=models.SET_NULL, null=True) 20 | object_id = models.CharField(max_length=255, null=True) 21 | generic_relation = GenericForeignKey("content_type", "object_id") 22 | 23 | class TestGenericRelation(SoftDeleteObject): 24 | generic_relations = GenericRelation("ProjectRiskModel") 25 | 26 | 27 | class TestModelTwoCascade(SoftDeleteObject): 28 | extra_int = models.IntegerField() 29 | tmo = models.ForeignKey( 30 | TestModelOne, 31 | on_delete=models.CASCADE, 32 | related_name='tmts' 33 | ) 34 | 35 | 36 | class TestModelTwoDoNothing(SoftDeleteObject): 37 | extra_int = models.IntegerField() 38 | tmo = models.ForeignKey( 39 | TestModelOne, 40 | on_delete=models.DO_NOTHING, 41 | related_name='tmdn' 42 | ) 43 | 44 | 45 | class TestModelTwoSetNull(SoftDeleteObject): 46 | extra_int = models.IntegerField() 47 | tmo = models.ForeignKey( 48 | TestModelOne, 49 | on_delete=models.SET_NULL, 50 | related_name='tmsn', 51 | null=True, 52 | blank=True 53 | ) 54 | 55 | 56 | class TestModelTwoSetNullOneToOne(SoftDeleteObject): 57 | extra_int = models.IntegerField() 58 | tmo = models.OneToOneField( 59 | TestModelOne, 60 | on_delete=models.SET_NULL, 61 | related_name='tmsno', 62 | null=True, 63 | blank=True 64 | ) 65 | 66 | 67 | class TestModelThree(SoftDeleteObject): 68 | tmos = models.ManyToManyField(TestModelOne, through='TestModelThrough') 69 | extra_int = models.IntegerField(blank=True, null=True) 70 | 71 | 72 | class TestModelThrough(SoftDeleteObject): 73 | tmo1 = models.ForeignKey( 74 | TestModelOne, 75 | on_delete=models.CASCADE, 76 | related_name="left_side" 77 | ) 78 | tmo3 = models.ForeignKey( 79 | TestModelThree, 80 | on_delete=models.CASCADE, 81 | related_name='right_side' 82 | ) 83 | 84 | 85 | class TestModelBaseO2OMale(SoftDeleteObject): 86 | name = models.CharField(max_length=16) 87 | 88 | 89 | class TestModelO2OFemaleSetNull(SoftDeleteObject): 90 | name = models.CharField(max_length=16) 91 | link = models.OneToOneField( 92 | TestModelBaseO2OMale, 93 | related_name='one_to_one_set_null', 94 | on_delete=models.SET_NULL, 95 | null=True, 96 | ) 97 | 98 | 99 | class TestModelO2OFemaleCascade(SoftDeleteObject): 100 | name = models.CharField(max_length=16) 101 | link = models.OneToOneField( 102 | TestModelBaseO2OMale, 103 | related_name='one_to_one_cascade', 104 | on_delete=models.CASCADE, 105 | ) 106 | 107 | 108 | class TestModelO2OFemaleCascadeErrorOnDelete(models.Model): 109 | name = models.CharField(max_length=16) 110 | link = models.OneToOneField( 111 | TestModelBaseO2OMale, 112 | related_name='one_to_one_cascade_error_on_delete', 113 | on_delete=models.CASCADE, 114 | ) 115 | 116 | def delete(self, using=None, keep_parents=False): 117 | raise ModelDeletionException("Preventing deletion!") 118 | 119 | 120 | class TestModelO2OFemaleCascadeNoSD(models.Model): 121 | name = models.CharField(max_length=16) 122 | link = models.OneToOneField( 123 | TestModelBaseO2OMale, 124 | related_name='one_to_one_cascade_no_sd', 125 | on_delete=models.CASCADE 126 | ) 127 | 128 | 129 | admin.site.register(TestModelOne, SoftDeleteObjectAdmin) 130 | admin.site.register(TestModelTwoCascade, SoftDeleteObjectAdmin) 131 | admin.site.register(TestModelTwoSetNull, SoftDeleteObjectAdmin) 132 | admin.site.register(TestModelTwoDoNothing, SoftDeleteObjectAdmin) 133 | 134 | admin.site.register(TestModelThree, SoftDeleteObjectAdmin) 135 | admin.site.register(TestModelThrough, SoftDeleteObjectAdmin) 136 | -------------------------------------------------------------------------------- /softdelete/admin/admin.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse, Http404, HttpResponseRedirect 2 | from django.core.exceptions import ObjectDoesNotExist 3 | from django.contrib import admin 4 | from django.db import models 5 | from softdelete.models import * 6 | from softdelete.admin.forms import * 7 | import logging 8 | 9 | 10 | class SoftDeleteObjectInline(admin.TabularInline): 11 | class Meta: 12 | exclude = ('deleted_at',) 13 | 14 | def __init__(self, parent, site, *args, **kwargs): 15 | super(SoftDeleteObjectInline, self).__init__(parent, site, *args, **kwargs) 16 | if parent.deleted: 17 | self.extra = 0 18 | self.max_num = 0 19 | 20 | def get_queryset(self, request): 21 | qs = self.model._default_manager.all_with_deleted() 22 | ordering = self.get_ordering(request) or () 23 | if ordering: 24 | qs = qs.order_by(*ordering) 25 | return qs 26 | 27 | queryset = get_queryset 28 | 29 | 30 | class SoftDeleteObjectAdmin(admin.ModelAdmin): 31 | form = SoftDeleteObjectAdminForm 32 | actions = ['delete_selected', 'soft_undelete', 'hard_delete'] 33 | 34 | def delete_selected(self, request, queryset): 35 | queryset.delete() 36 | 37 | delete_selected.short_description = 'Soft delete selected objects' 38 | 39 | def soft_undelete(self, request, queryset): 40 | queryset.undelete() 41 | 42 | soft_undelete.short_description = 'Undelete selected objects' 43 | 44 | def hard_delete(self, request, queryset): 45 | for obj in queryset: 46 | if obj.deleted_at: 47 | obj.delete() 48 | else: 49 | obj.hard_delete() 50 | hard_delete.short_description = 'Hard delete selected objects' 51 | 52 | def response_change(self, request, obj, *args, **kwargs): 53 | if 'undelete' in request.POST: 54 | return HttpResponseRedirect('../') 55 | return super(SoftDeleteObjectAdmin, self).response_change(request, obj, *args, **kwargs) 56 | 57 | def get_queryset(self, request): 58 | try: 59 | qs = self.model._default_manager.all_with_deleted() 60 | except Exception as ex: 61 | qs = self.model._default_manager.all() 62 | 63 | ordering = self.get_ordering(request) or () 64 | if ordering: 65 | qs = qs.order_by(*ordering) 66 | return qs 67 | 68 | queryset = get_queryset 69 | 70 | 71 | class SoftDeleteRecordInline(admin.TabularInline): 72 | model = SoftDeleteRecord 73 | max_num = 0 74 | exclude = ('content_type', 'object_id',) 75 | readonly_fields = ('content',) 76 | 77 | 78 | class SoftDeleteRecordAdmin(admin.ModelAdmin): 79 | model = SoftDeleteRecord 80 | form = SoftDeleteRecordAdminForm 81 | actions = ['soft_undelete'] 82 | 83 | def soft_undelete(self, request, queryset): 84 | [x.undelete() for x in queryset.all()] 85 | 86 | soft_undelete.short_description = 'Undelete selected objects' 87 | 88 | def response_change(self, request, obj, *args, **kwargs): 89 | if 'undelete' in request.POST: 90 | obj.undelete() 91 | return HttpResponseRedirect('../../') 92 | return super(SoftDeleteRecordAdmin, self).response_change(request, obj, 93 | *args, **kwargs) 94 | 95 | def has_add_permission(self, *args, **kwargs): 96 | return False 97 | 98 | def has_delete_permission(self, *args, **kwargs): 99 | return False 100 | 101 | 102 | class ChangeSetAdmin(admin.ModelAdmin): 103 | model = ChangeSet 104 | form = ChangeSetAdminForm 105 | inlines = (SoftDeleteRecordInline,) 106 | actions = ['soft_undelete'] 107 | 108 | def soft_undelete(self, request, queryset): 109 | [x.undelete() for x in queryset.all()] 110 | 111 | soft_undelete.short_description = 'Undelete selected objects' 112 | 113 | def response_change(self, request, obj, *args, **kwargs): 114 | if 'undelete' in request.POST: 115 | obj.undelete() 116 | return HttpResponseRedirect('../../') 117 | return super(ChangeSetAdmin, self).response_change(request, obj, 118 | *args, **kwargs) 119 | 120 | def has_add_permission(self, *args, **kwargs): 121 | return False 122 | 123 | def has_delete_permission(self, *args, **kwargs): 124 | return False 125 | 126 | 127 | admin.site.register(SoftDeleteRecord, SoftDeleteRecordAdmin) 128 | admin.site.register(ChangeSet, ChangeSetAdmin) 129 | -------------------------------------------------------------------------------- /softdelete/south_migrations/0002_auto__del_recordset__del_unique_recordset_changeset_content_type_objec.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | class Migration(SchemaMigration): 8 | 9 | def forwards(self, orm): 10 | 11 | # Removing unique constraint on 'RecordSet', fields ['changeset', 'content_type', 'object_id'] 12 | db.delete_unique('softdelete_recordset', ['changeset_id', 'content_type_id', 'object_id']) 13 | 14 | # Deleting model 'RecordSet' 15 | db.delete_table('softdelete_recordset') 16 | 17 | # Adding model 'SoftDeleteRecord' 18 | db.create_table('softdelete_softdeleterecord', ( 19 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 20 | ('changeset', self.gf('django.db.models.fields.related.ForeignKey')(related_name='recordsets', to=orm['softdelete.ChangeSet'])), 21 | ('created_date', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.utcnow)), 22 | ('content_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'])), 23 | ('object_id', self.gf('django.db.models.fields.PositiveIntegerField')()), 24 | )) 25 | db.send_create_signal('softdelete', ['SoftDeleteRecord']) 26 | 27 | # Adding unique constraint on 'SoftDeleteRecord', fields ['changeset', 'content_type', 'object_id'] 28 | db.create_unique('softdelete_softdeleterecord', ['changeset_id', 'content_type_id', 'object_id']) 29 | 30 | 31 | def backwards(self, orm): 32 | 33 | # Removing unique constraint on 'SoftDeleteRecord', fields ['changeset', 'content_type', 'object_id'] 34 | db.delete_unique('softdelete_softdeleterecord', ['changeset_id', 'content_type_id', 'object_id']) 35 | 36 | # Adding model 'RecordSet' 37 | db.create_table('softdelete_recordset', ( 38 | ('changeset', self.gf('django.db.models.fields.related.ForeignKey')(related_name='recordsets', to=orm['softdelete.ChangeSet'])), 39 | ('object_id', self.gf('django.db.models.fields.PositiveIntegerField')()), 40 | ('content_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'])), 41 | ('created_date', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.utcnow)), 42 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 43 | )) 44 | db.send_create_signal('softdelete', ['RecordSet']) 45 | 46 | # Adding unique constraint on 'RecordSet', fields ['changeset', 'content_type', 'object_id'] 47 | db.create_unique('softdelete_recordset', ['changeset_id', 'content_type_id', 'object_id']) 48 | 49 | # Deleting model 'SoftDeleteRecord' 50 | db.delete_table('softdelete_softdeleterecord') 51 | 52 | 53 | models = { 54 | 'contenttypes.contenttype': { 55 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 56 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 57 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 58 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 59 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 60 | }, 61 | 'softdelete.changeset': { 62 | 'Meta': {'object_name': 'ChangeSet'}, 63 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 64 | 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow'}), 65 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 66 | 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) 67 | }, 68 | 'softdelete.softdeleterecord': { 69 | 'Meta': {'unique_together': "(('changeset', 'content_type', 'object_id'),)", 'object_name': 'SoftDeleteRecord'}, 70 | 'changeset': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'recordsets'", 'to': "orm['softdelete.ChangeSet']"}), 71 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 72 | 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow'}), 73 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 74 | 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) 75 | } 76 | } 77 | 78 | complete_apps = ['softdelete'] 79 | -------------------------------------------------------------------------------- /softdelete/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import django 4 | 5 | from django.conf import settings 6 | from django.db.models import query, OneToOneRel 7 | from django.db import models, transaction 8 | from django.core.exceptions import ObjectDoesNotExist 9 | from django.contrib.contenttypes.models import ContentType 10 | try: 11 | from django.contrib.contenttypes.fields import GenericForeignKey 12 | except ImportError: 13 | from django.contrib.contenttypes.generic import GenericForeignKey 14 | from django.contrib.auth.models import Group, Permission 15 | from django.utils import timezone 16 | import logging 17 | from softdelete.signals import * 18 | 19 | try: 20 | USE_SOFTDELETE_GROUP = settings.USE_SOFTDELETE_GROUP 21 | except: 22 | USE_SOFTDELETE_GROUP = False 23 | 24 | 25 | def _determine_change_set(obj, create=True): 26 | try: 27 | qs = SoftDeleteRecord.objects.filter(content_type=ContentType.objects.get_for_model(obj), 28 | object_id=str(obj.pk)).latest('created_date').changeset 29 | logging.debug("Found changeset via latest recordset") 30 | except: 31 | try: 32 | qs = ChangeSet.objects.filter(content_type=ContentType.objects.get_for_model(obj), 33 | object_id=str(obj.pk)).latest('created_date') 34 | logging.debug("Found changeset") 35 | except: 36 | if create: 37 | qs = ChangeSet.objects.create(content_type=ContentType.objects.get_for_model(obj), 38 | object_id=str(obj.pk)) 39 | logging.debug("Creating changeset") 40 | else: 41 | logging.debug("Raising ObjectDoesNotExist") 42 | raise ObjectDoesNotExist 43 | return qs 44 | 45 | 46 | class SoftDeleteQuerySet(query.QuerySet): 47 | def all_with_deleted(self): 48 | qs = super(SoftDeleteQuerySet, self).all() 49 | qs.__class__ = SoftDeleteQuerySet 50 | return qs 51 | 52 | def delete(self, using='default', *args, **kwargs): 53 | if not len(self): 54 | return 55 | cs = kwargs.get('changeset') 56 | logging.debug("STARTING QUERYSET SOFT-DELETE: %s. %s", self, len(self)) 57 | for obj in self: 58 | rs, c = SoftDeleteRecord.objects.get_or_create(changeset=cs or _determine_change_set(obj), 59 | content_type=ContentType.objects.get_for_model(obj), 60 | object_id=str(obj.pk)) 61 | logging.debug(" ----- CALLING delete() on %s", obj) 62 | obj.delete(using, *args, **kwargs) 63 | 64 | def undelete(self, using='default', *args, **kwargs): 65 | logging.debug("UNDELETING %s", self) 66 | for obj in self: 67 | cs = _determine_change_set(obj) 68 | cs.undelete() 69 | logging.debug("FINISHED UNDELETING %s", self) 70 | 71 | 72 | class SoftDeleteManager(models.Manager): 73 | 74 | def _get_base_queryset(self): 75 | ''' 76 | Convenience method for grabbing the base query set. Accounts for the 77 | deprecation of get_query_set in Django 18. 78 | ''' 79 | 80 | if django.VERSION >= (1, 8, 0, 'final', 0): 81 | return super(SoftDeleteManager, self).get_queryset() 82 | else: 83 | return super(SoftDeleteManager, self).get_query_set() 84 | 85 | def _get_self_queryset(self): 86 | ''' 87 | Convenience method for grabbing the query set. Accounts for the 88 | deprecation of get_query_set in Django 1.8 89 | ''' 90 | 91 | if django.VERSION >= (1, 8, 0, 'final', 0): 92 | return self.get_queryset() 93 | else: 94 | return self.get_query_set() 95 | 96 | def get_query_set(self): 97 | qs = super(SoftDeleteManager, self).get_query_set().filter( 98 | deleted_at__isnull=True) 99 | if not issubclass(qs.__class__, SoftDeleteQuerySet): 100 | qs.__class__ = SoftDeleteQuerySet 101 | return qs 102 | 103 | def get_queryset(self): 104 | qs = super(SoftDeleteManager, self).get_queryset().filter( 105 | deleted_at__isnull=True) 106 | if not issubclass(qs.__class__, SoftDeleteQuerySet): 107 | qs.__class__ = SoftDeleteQuerySet 108 | return qs 109 | 110 | def all_with_deleted(self, prt=False): 111 | if hasattr(self, 'core_filters'): # it's a RelatedManager 112 | qs = self._get_base_queryset().filter(**self.core_filters) 113 | else: 114 | qs = self._get_base_queryset() 115 | if not issubclass(qs.__class__, SoftDeleteQuerySet): 116 | qs.__class__ = SoftDeleteQuerySet 117 | return qs 118 | 119 | def deleted_set(self): 120 | qs = self._get_base_queryset().filter(deleted_at__isnull=False) 121 | if not issubclass(qs.__class__, SoftDeleteQuerySet): 122 | qs.__class__ = SoftDeleteQuerySet 123 | return qs 124 | 125 | def get(self, *args, **kwargs): 126 | if 'pk' in kwargs: 127 | return self.all_with_deleted().get(*args, **kwargs) 128 | else: 129 | return self._get_self_queryset().get(*args, **kwargs) 130 | 131 | def filter(self, *args, **kwargs): 132 | if 'pk' in kwargs: 133 | qs = self.all_with_deleted().filter(*args, **kwargs) 134 | else: 135 | qs = self._get_self_queryset().filter(*args, **kwargs) 136 | if not issubclass(qs.__class__, SoftDeleteQuerySet): 137 | qs.__class__ = SoftDeleteQuerySet 138 | return qs 139 | 140 | 141 | class SoftDeleteObject(models.Model): 142 | deleted_at = models.DateTimeField( 143 | blank=True, null=True, default=None, 144 | editable=False, db_index=True 145 | ) 146 | objects = SoftDeleteManager() 147 | 148 | class Meta: 149 | abstract = True 150 | permissions = ( 151 | ('can_undelete', 'Can undelete this object'), 152 | ) 153 | 154 | def __init__(self, *args, **kwargs): 155 | super(SoftDeleteObject, self).__init__(*args, **kwargs) 156 | self.__dirty = False 157 | 158 | def get_deleted(self): 159 | return self.deleted_at is not None 160 | 161 | def set_deleted(self, d): 162 | """Called via the admin interface (if user checks the "deleted" checkox)""" 163 | if d and not self.deleted_at: 164 | self.__dirty = True 165 | self.deleted_at = timezone.now() 166 | elif not d and self.deleted_at: 167 | self.__dirty = True 168 | self.deleted_at = None 169 | 170 | deleted = property(get_deleted, set_deleted) 171 | 172 | def _do_delete(self, changeset, related): 173 | rel = related.get_accessor_name() 174 | 175 | # Sometimes there is nothing to delete 176 | if not hasattr(self, rel): 177 | return 178 | 179 | try: 180 | if related.one_to_one: 181 | getattr(self, rel).delete(changeset=changeset) 182 | else: 183 | getattr(self, rel).all().delete(changeset=changeset) 184 | except: 185 | try: 186 | if related.one_to_one: 187 | getattr(self, rel).delete() 188 | else: 189 | getattr(self, rel).all().delete() 190 | except Exception as e: 191 | if getattr(settings, "SOFTDELETE_CASCADE_ALLOW_DELETE_ALL", True): 192 | # fallback to delete all objects in the related field's model class 193 | # to maintain previous behaviour (before setting was added) 194 | try: 195 | getattr(self, rel).__class__.objects.all().delete( 196 | changeset=changeset) 197 | except: 198 | getattr(self, rel).__class__.objects.all().delete() 199 | else: 200 | raise e 201 | 202 | @transaction.atomic 203 | def hard_delete(self, *args, **kwargs): 204 | super(SoftDeleteObject, self).delete(*args, **kwargs) 205 | 206 | def delete(self, *args, **kwargs): 207 | if self.deleted_at: 208 | logging.debug("HARD DELETEING type %s, %s", type(self), self) 209 | try: 210 | cs = ChangeSet.objects.get( 211 | content_type=ContentType.objects.get_for_model(self), 212 | object_id=self.pk) 213 | cs.delete() 214 | super(SoftDeleteObject, self).delete(*args, **kwargs) 215 | except: 216 | try: 217 | cs = kwargs.get('changeset') or _determine_change_set(self) 218 | rs = SoftDeleteRecord.objects.get( 219 | changeset=cs, 220 | content_type=ContentType.objects.get_for_model(self), 221 | object_id=self.pk) 222 | if rs.changeset.soft_delete_records.count() == 1: 223 | cs.delete() 224 | else: 225 | rs.delete() 226 | super(SoftDeleteObject, self).delete(*args, **kwargs) 227 | except: 228 | pass 229 | else: 230 | using = kwargs.get('using', 'default') 231 | models.signals.pre_delete.send(sender=self.__class__, 232 | instance=self, 233 | using=using) 234 | pre_soft_delete.send(sender=self.__class__, 235 | instance=self, 236 | using=using) 237 | logging.debug('SOFT DELETING type: %s, %s', type(self), self) 238 | cs = kwargs.get('changeset') or _determine_change_set(self) 239 | SoftDeleteRecord.objects.get_or_create( 240 | changeset=cs, 241 | content_type=ContentType.objects.get_for_model(self), 242 | object_id=self.pk) 243 | self.deleted_at = timezone.now() 244 | self.save() 245 | all_related = [ 246 | f for f in self._meta.get_fields() 247 | if (f.one_to_many or f.one_to_one) 248 | and f.auto_created and not f.concrete 249 | ] 250 | 251 | all_generic_relations = [ 252 | f 253 | for f in self._meta.get_fields() 254 | if (f.one_to_many or f.one_to_one) 255 | and hasattr(f, "reverse_related_fields") 256 | and not f.concrete 257 | ] 258 | 259 | for generic_relation in all_generic_relations: 260 | related_objects = generic_relation.bulk_related_objects( 261 | [self], using=using 262 | ) 263 | for related_object in related_objects: 264 | related_object.delete() 265 | 266 | for x in all_related: 267 | if x.on_delete.__name__ not in ['DO_NOTHING', 'SET_NULL']: 268 | self._do_delete(cs, x) 269 | if x.on_delete.__name__ == 'SET_NULL': 270 | related_name = x.get_accessor_name() 271 | if isinstance(x, OneToOneRel): 272 | if getattr(self, related_name, None) is None: 273 | continue 274 | related = getattr(self, related_name) 275 | if isinstance(related, models.Model): 276 | setattr(related, x.remote_field.name, None) 277 | related.save(update_fields=[x.remote_field.name]) 278 | else: 279 | getattr(self, related_name).all().update(**{x.remote_field.name: None}) 280 | logging.debug("FINISHED SOFT DELETING RELATED %s", self) 281 | models.signals.post_delete.send(sender=self.__class__, 282 | instance=self, 283 | using=using) 284 | post_soft_delete.send(sender=self.__class__, 285 | instance=self, 286 | using=using) 287 | 288 | def _do_undelete(self, using='default'): 289 | pre_undelete.send(sender=self.__class__, 290 | instance=self, 291 | using=using) 292 | self.deleted_at = None 293 | self.save() 294 | post_undelete.send(sender=self.__class__, 295 | instance=self, 296 | using=using) 297 | 298 | def undelete(self, using='default', *args, **kwargs): 299 | logging.debug('UNDELETING %s' % self) 300 | cs = kwargs.get('changeset') or _determine_change_set(self, False) 301 | cs.undelete(using) 302 | logging.debug('FINISHED UNDELETING RELATED %s', self) 303 | 304 | def save(self, **kwargs): 305 | super(SoftDeleteObject, self).save(**kwargs) 306 | if self.__dirty: 307 | self.__dirty = False 308 | if not self.deleted: 309 | self.undelete() 310 | else: 311 | self.delete() 312 | 313 | class ChangeSet(models.Model): 314 | id = models.BigAutoField( 315 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 316 | ) 317 | created_date = models.DateTimeField(default=timezone.now) 318 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 319 | object_id = models.CharField(max_length=100) 320 | record = GenericForeignKey('content_type', 'object_id') 321 | 322 | class Meta: 323 | indexes = [ 324 | models.Index(fields=["content_type", "object_id"]), 325 | ] 326 | 327 | def get_content(self): 328 | self.record = self.content_type.model_class().objects.get( 329 | pk=self.object_id) 330 | return self.record 331 | 332 | def set_content(self, obj): 333 | self.record = obj 334 | 335 | def undelete(self, using='default'): 336 | logging.debug("CHANGESET UNDELETE: %s" % self) 337 | self.content._do_undelete(using) 338 | for related in self.soft_delete_records.all(): 339 | related.undelete(using) 340 | self.delete() 341 | logging.debug("FINISHED CHANGESET UNDELETE: %s", self) 342 | 343 | def __str__(self): 344 | return 'Changeset: %s, %s' % (self.created_date, self.record) 345 | 346 | content = property(get_content, set_content) 347 | 348 | class SoftDeleteRecord(models.Model): 349 | id = models.BigAutoField( 350 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 351 | ) 352 | changeset = models.ForeignKey( 353 | ChangeSet, 354 | related_name='soft_delete_records', 355 | on_delete=models.CASCADE 356 | ) 357 | created_date = models.DateTimeField(default=timezone.now) 358 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 359 | object_id = models.CharField(max_length=100) 360 | record = GenericForeignKey('content_type', 'object_id') 361 | 362 | class Meta: 363 | unique_together = (('changeset', 'content_type', 'object_id'),) 364 | indexes = [ 365 | models.Index(fields=["content_type", "object_id"]), 366 | ] 367 | 368 | def get_content(self): 369 | self.record = self.content_type.model_class().objects.get(pk=self.object_id) 370 | return self.record 371 | 372 | def set_content(self, obj): 373 | self.record = obj 374 | 375 | def undelete(self, using='default'): 376 | self.content._do_undelete(using) 377 | 378 | def __str__(self): 379 | return u'SoftDeleteRecord: (%s), (%s/%s), %s' % ( 380 | self.content, 381 | self.content_type, 382 | self.object_id, 383 | self.changeset.created_date) 384 | 385 | content = property(get_content, set_content) 386 | 387 | 388 | def assign_permissions(user_or_group): 389 | for model in ['ChangeSet', 'SoftDeleteRecord']: 390 | ct = ContentType.objects.get(app_label="softdelete", 391 | model=model.lower()) 392 | p, pc = Permission.objects.get_or_create( 393 | name="Can undelete a soft-deleted object", 394 | codename="can_undelete", 395 | content_type=ct) 396 | permissions = [p] 397 | for permission in ['add_%s' % model.lower(), 398 | 'change_%s' % model.lower(), 399 | 'delete_%s' % model.lower(), 400 | 'can_undelete']: 401 | for perm_obj in Permission.objects.filter(codename=permission): 402 | permissions.append(perm_obj) 403 | perm_list = getattr(user_or_group, 'permissions', 404 | getattr(user_or_group, 'user_permissions')) 405 | [perm_list.add(x) for x in permissions] 406 | user_or_group.save() 407 | return user_or_group 408 | 409 | 410 | def create_group(): 411 | if USE_SOFTDELETE_GROUP: 412 | gr, cr = Group.objects.get_or_create(name='Softdelete User') 413 | if cr: 414 | assign_permissions(gr) 415 | return gr 416 | -------------------------------------------------------------------------------- /softdelete/tests/test_sd.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ObjectDoesNotExist 3 | from django.test import TestCase, Client, override_settings 4 | from django.contrib.auth.models import User 5 | from django.db import models 6 | from django.contrib.contenttypes.models import ContentType 7 | from softdelete.test_softdelete_app.exceptions import ModelDeletionException 8 | from softdelete.test_softdelete_app.models import ( 9 | TestModelOne, 10 | TestModelTwoCascade, 11 | TestModelThree, 12 | TestModelThrough, 13 | TestModelTwoDoNothing, 14 | TestModelTwoSetNull, 15 | TestModelTwoSetNullOneToOne, 16 | TestModelO2OFemaleSetNull, 17 | TestModelBaseO2OMale, 18 | TestModelO2OFemaleCascade, 19 | TestModelO2OFemaleCascadeNoSD, 20 | TestModelO2OFemaleCascadeErrorOnDelete, 21 | TestGenericRelation, 22 | TestGenericForeignKey, 23 | ) 24 | from softdelete.tests.constanats import TEST_MODEL_ONE_COUNT, TEST_MODEL_TWO_TOTAL_COUNT, TEST_MODEL_THREE_COUNT, \ 25 | TEST_MODEL_TWO_LIST, TEST_MODEL_TWO_CASCADE_COUNT, TEST_MODEL_TWO_SET_NULL_COUNT, TEST_MODEL_TWO_DO_NOTHING_COUNT 26 | from softdelete.models import * 27 | from softdelete.signals import * 28 | import logging 29 | 30 | try: 31 | from django.core.urlresolvers import reverse 32 | except ImportError: 33 | from django.urls import reverse 34 | 35 | 36 | class BaseTest(TestCase): 37 | def setUp(self): 38 | # update TEST_MODEL_ONE_COUNT constant if you initialize more or less instance of TestModelOne 39 | self.tmo1 = TestModelOne.objects.create(extra_bool=True) 40 | self.tmo2 = TestModelOne.objects.create(extra_bool=False) 41 | 42 | for x in range(TEST_MODEL_TWO_TOTAL_COUNT): 43 | TEST_MODEL_TWO_LIST[x % len(TEST_MODEL_TWO_LIST)] \ 44 | .objects.create(extra_int=x, tmo=self.tmo1 if x % TEST_MODEL_ONE_COUNT else self.tmo2) 45 | for x in range(TEST_MODEL_TWO_TOTAL_COUNT): 46 | if x % TEST_MODEL_ONE_COUNT: 47 | left_side = self.tmo1 48 | else: 49 | left_side = self.tmo2 50 | for x in range(TEST_MODEL_TWO_TOTAL_COUNT): 51 | t3 = TestModelThree.objects.create() 52 | tmt = TestModelThrough.objects.create(tmo1=left_side, tmo3=t3) 53 | self.user = User.objects.create_user(username='SoftdeleteUser', 54 | password='SoftdeletePassword', 55 | email='softdeleteuser@example.com') 56 | gr = create_group() 57 | if USE_SOFTDELETE_GROUP: 58 | gr = Group.objects.get(name="Softdelete User") 59 | self.user.groups.add(gr) 60 | self.user.save() 61 | gr.save() 62 | else: 63 | assign_permissions(self.user) 64 | self.user.save() 65 | self.unauthorized = User.objects.create_user(username='NonSoftdeleteUser', 66 | password='NonSoftdeletePassword', 67 | email='nonsoftdeleteuser@example.com') 68 | 69 | 70 | class InitialTest(BaseTest): 71 | 72 | def test_simple_delete(self): 73 | self.assertEquals(TEST_MODEL_ONE_COUNT, TestModelOne.objects.count()) 74 | 75 | self.assertEquals(TEST_MODEL_ONE_COUNT, TestModelOne.objects.all_with_deleted().count()) 76 | 77 | self.assertEquals(TEST_MODEL_TWO_CASCADE_COUNT, TestModelTwoCascade.objects.count()) 78 | self.assertEquals(TEST_MODEL_TWO_SET_NULL_COUNT, TestModelTwoSetNull.objects.count()) 79 | self.assertEquals(TEST_MODEL_TWO_DO_NOTHING_COUNT, TestModelTwoDoNothing.objects.count()) 80 | 81 | self.assertEquals(TEST_MODEL_TWO_CASCADE_COUNT, TestModelTwoCascade.objects.all_with_deleted().count()) 82 | self.assertEquals(TEST_MODEL_TWO_DO_NOTHING_COUNT, TestModelTwoDoNothing.objects.all_with_deleted().count()) 83 | self.assertEquals(TEST_MODEL_TWO_SET_NULL_COUNT, TestModelTwoSetNull.objects.all_with_deleted().count()) 84 | 85 | self.assertEquals(TEST_MODEL_THREE_COUNT, TestModelThree.objects.count()) 86 | 87 | self.assertEquals(TEST_MODEL_THREE_COUNT, TestModelThree.objects.all_with_deleted().count()) 88 | 89 | self.tmo1.delete() 90 | 91 | self.assertEquals(TEST_MODEL_ONE_COUNT - 1, TestModelOne.objects.count()) 92 | self.assertEquals(TEST_MODEL_ONE_COUNT, TestModelOne.objects.all_with_deleted().count()) 93 | 94 | self.assertEquals(TEST_MODEL_TWO_CASCADE_COUNT - 2, TestModelTwoCascade.objects.count()) 95 | self.assertEquals(TEST_MODEL_TWO_SET_NULL_COUNT, TestModelTwoSetNull.objects.count()) 96 | self.assertEquals(TEST_MODEL_TWO_DO_NOTHING_COUNT, TestModelTwoDoNothing.objects.count()) 97 | 98 | self.assertEquals(TEST_MODEL_TWO_CASCADE_COUNT, TestModelTwoCascade.objects.all_with_deleted().count()) 99 | self.assertEquals(TEST_MODEL_TWO_SET_NULL_COUNT, TestModelTwoSetNull.objects.all_with_deleted().count()) 100 | self.assertEquals(TEST_MODEL_TWO_DO_NOTHING_COUNT, TestModelTwoDoNothing.objects.all_with_deleted().count()) 101 | 102 | self.assertEquals(TEST_MODEL_THREE_COUNT, TestModelThree.objects.count()) 103 | self.assertEquals(TEST_MODEL_THREE_COUNT, TestModelThree.objects.all_with_deleted().count()) 104 | 105 | def test_simple_multi_delete(self): 106 | self.assertEquals(TEST_MODEL_ONE_COUNT, TestModelOne.objects.count()) 107 | 108 | self.assertEquals(TEST_MODEL_ONE_COUNT, TestModelOne.objects.all_with_deleted().count()) 109 | 110 | self.assertEquals(TEST_MODEL_TWO_CASCADE_COUNT, TestModelTwoCascade.objects.count()) 111 | self.assertEquals(TEST_MODEL_TWO_SET_NULL_COUNT, TestModelTwoSetNull.objects.count()) 112 | self.assertEquals(TEST_MODEL_TWO_DO_NOTHING_COUNT, TestModelTwoDoNothing.objects.count()) 113 | 114 | self.assertEquals(TEST_MODEL_TWO_CASCADE_COUNT, TestModelTwoCascade.objects.all_with_deleted().count()) 115 | self.assertEquals(TEST_MODEL_TWO_DO_NOTHING_COUNT, TestModelTwoDoNothing.objects.all_with_deleted().count()) 116 | self.assertEquals(TEST_MODEL_TWO_SET_NULL_COUNT, TestModelTwoSetNull.objects.all_with_deleted().count()) 117 | 118 | self.assertEquals(TEST_MODEL_THREE_COUNT, TestModelThree.objects.count()) 119 | 120 | self.assertEquals(TEST_MODEL_THREE_COUNT, TestModelThree.objects.all_with_deleted().count()) 121 | 122 | self.tmo1.delete() 123 | self.tmo2.delete() 124 | 125 | self.assertEquals(TEST_MODEL_ONE_COUNT - 2, TestModelOne.objects.count()) 126 | self.assertEquals(TEST_MODEL_ONE_COUNT, TestModelOne.objects.all_with_deleted().count()) 127 | 128 | self.assertEquals(TEST_MODEL_TWO_CASCADE_COUNT - 4, TestModelTwoCascade.objects.count()) 129 | self.assertEquals(TEST_MODEL_TWO_SET_NULL_COUNT, TestModelTwoSetNull.objects.count()) 130 | self.assertEquals(TEST_MODEL_TWO_DO_NOTHING_COUNT, TestModelTwoDoNothing.objects.count()) 131 | 132 | self.assertEquals(TEST_MODEL_TWO_CASCADE_COUNT, TestModelTwoCascade.objects.all_with_deleted().count()) 133 | self.assertEquals(TEST_MODEL_TWO_SET_NULL_COUNT, TestModelTwoSetNull.objects.all_with_deleted().count()) 134 | self.assertEquals(TEST_MODEL_TWO_DO_NOTHING_COUNT, TestModelTwoDoNothing.objects.all_with_deleted().count()) 135 | 136 | self.assertEquals(TEST_MODEL_THREE_COUNT, TestModelThree.objects.count()) 137 | self.assertEquals(TEST_MODEL_THREE_COUNT, TestModelThree.objects.all_with_deleted().count()) 138 | 139 | 140 | class DeleteTest(BaseTest): 141 | def pre_delete(self, *args, **kwargs): 142 | self.pre_delete_called = True 143 | 144 | def post_delete(self, *args, **kwargs): 145 | self.post_delete_called = True 146 | 147 | def pre_soft_delete(self, *args, **kwargs): 148 | self.pre_soft_delete_called = True 149 | 150 | def post_soft_delete(self, *args, **kwargs): 151 | self.post_soft_delete_called = True 152 | 153 | def _pretest(self): 154 | self.pre_delete_called = False 155 | self.post_delete_called = False 156 | self.pre_soft_delete_called = False 157 | self.post_soft_delete_called = False 158 | models.signals.pre_delete.connect(self.pre_delete) 159 | models.signals.post_delete.connect(self.post_delete) 160 | pre_soft_delete.connect(self.pre_soft_delete) 161 | post_soft_delete.connect(self.post_soft_delete) 162 | self.assertEquals(TEST_MODEL_ONE_COUNT, TestModelOne.objects.count()) 163 | self.assertEquals(TEST_MODEL_TWO_CASCADE_COUNT, TestModelTwoCascade.objects.count()) 164 | self.assertEquals(TEST_MODEL_TWO_SET_NULL_COUNT, TestModelTwoSetNull.objects.count()) 165 | self.assertEquals(TEST_MODEL_TWO_DO_NOTHING_COUNT, TestModelTwoDoNothing.objects.count()) 166 | self.assertEquals(TEST_MODEL_THREE_COUNT, TestModelThree.objects.count()) 167 | self.assertFalse(self.tmo1.deleted) 168 | self.assertFalse(self.pre_delete_called) 169 | self.assertFalse(self.post_delete_called) 170 | self.assertFalse(self.pre_soft_delete_called) 171 | self.assertFalse(self.post_soft_delete_called) 172 | self.cs_count = ChangeSet.objects.count() 173 | self.rs_count = SoftDeleteRecord.objects.count() 174 | 175 | def _posttest(self): 176 | self.tmo1 = TestModelOne.objects.get(pk=self.tmo1.pk) 177 | self.tmo2 = TestModelOne.objects.get(pk=self.tmo2.pk) 178 | self.assertTrue(self.tmo1.deleted) 179 | self.assertFalse(self.tmo2.deleted) 180 | self.assertTrue(self.pre_delete_called) 181 | self.assertTrue(self.post_delete_called) 182 | self.assertTrue(self.pre_soft_delete_called) 183 | self.assertTrue(self.post_soft_delete_called) 184 | self.tmo1.undelete() 185 | 186 | def test_delete(self): 187 | self._pretest() 188 | self.tmo1.delete() 189 | self.assertEquals(self.cs_count + 1, ChangeSet.objects.count()) 190 | self.assertEquals(self.rs_count + (TEST_MODEL_THREE_COUNT // 2 # half is assigned by tmo1 191 | + TEST_MODEL_TWO_CASCADE_COUNT // 2 # half assigned to cascade model two 192 | + 1 # tmo1 itself 193 | ), SoftDeleteRecord.objects.count()) 194 | self._posttest() 195 | 196 | def test_hard_delete(self): 197 | self._pretest() 198 | tmo_tmp = TestModelOne.objects.create(extra_bool=True) 199 | tmo_tmp.delete() 200 | self.assertEquals(self.cs_count + 1, ChangeSet.objects.count()) 201 | self.assertEquals(self.rs_count + 1, SoftDeleteRecord.objects.count()) 202 | tmo_tmp.delete() 203 | self.assertEquals(self.cs_count, ChangeSet.objects.count()) 204 | self.assertEquals(self.rs_count, SoftDeleteRecord.objects.count()) 205 | self.assertRaises(TestModelOne.DoesNotExist, 206 | TestModelOne.objects.get, 207 | pk=tmo_tmp.pk) 208 | 209 | def test_filter_delete(self): 210 | self._pretest() 211 | TestModelOne.objects.filter(pk=1).delete() 212 | self.assertEquals(self.cs_count + 1, ChangeSet.objects.count()) 213 | self.assertEquals(self.rs_count + (TEST_MODEL_THREE_COUNT // 2 # half is assigned by tmo1 214 | + TEST_MODEL_TWO_CASCADE_COUNT // 2 # half assigned to cascade model two 215 | + 1 # tmo1 itself 216 | ), SoftDeleteRecord.objects.count()) 217 | self._posttest() 218 | 219 | def test_set_null_on_one_to_one(self): 220 | """ 221 | Make sure reverse `OneToOne` fields are set to `None` upon soft delete. 222 | 223 | When an instance is soft deleted and other instances have a `OneToOne` 224 | relation to this instance with `on_delete=SET_NULL`, the other 225 | instances should have their relation set to `None`. 226 | """ 227 | # Create two instances, one with a relation to the other. 228 | to_be_deleted = TestModelOne.objects.create() 229 | other_with_relation = TestModelTwoSetNullOneToOne.objects.create( 230 | tmo=to_be_deleted, 231 | extra_int=0, 232 | ) 233 | 234 | # Make sure the relation is there before soft deleting. 235 | self.assertEqual(other_with_relation.tmo, to_be_deleted) 236 | 237 | # Then delete the instance and expect the relation from the other 238 | # instance is now `None`. 239 | to_be_deleted.delete() 240 | other_with_relation.refresh_from_db() 241 | self.assertIsNone(other_with_relation.tmo) 242 | 243 | def test_delete_generic_relation(self): 244 | test_generic_relation = TestGenericRelation.objects.create() 245 | test_generic_foreign_key = TestGenericForeignKey.objects.create( 246 | content_type=ContentType.objects.get_for_model(TestGenericRelation), 247 | object_id=test_generic_relation.pk, 248 | ) 249 | test_generic_relation.delete() 250 | test_generic_foreign_key.refresh_from_db() 251 | test_generic_relation.refresh_from_db() 252 | self.assertIsNotNone(test_generic_relation.deleted_at) 253 | self.assertIsNotNone(test_generic_foreign_key.deleted_at) 254 | 255 | 256 | class AdminTest(BaseTest): 257 | def test_admin(self): 258 | client = Client() 259 | u = User.objects.create_user(username='test-user', password='test', 260 | email='test-user@example.com') 261 | u.is_staff = True 262 | u.is_superuser = True 263 | u.save() 264 | self.assertFalse(self.tmo1.deleted) 265 | client.login(username='test-user', password='test') 266 | url = '/admin/test_softdelete_app/testmodelone/1/' 267 | tmo = client.get(url) 268 | # the admin URLs changed with v1.9 change our expectation if it makes sense version wise. 269 | if tmo.status_code == 302 and tmo['Location'].endswith('change/') and (1, 9) <= django.VERSION: 270 | url = tmo['Location'] 271 | tmo = client.get(url) 272 | self.assertEquals(tmo.status_code, 200) 273 | tmo = client.post(url, {'extra_bool': '1', 'deleted': '1'}) 274 | self.assertEquals(tmo.status_code, 302) 275 | self.tmo1 = TestModelOne.objects.get(pk=self.tmo1.pk) 276 | self.assertTrue(self.tmo1.deleted) 277 | 278 | 279 | class AuthorizationTest(BaseTest): 280 | def test_permission_needed(self): 281 | cl = Client() 282 | cl.login(username='NonSoftdeleteUser', 283 | password='NonSoftdeletePassword') 284 | rv = cl.get(reverse('softdelete.changeset.list')) 285 | self.assertEquals(rv.status_code, 302) 286 | rv = cl.get(reverse('softdelete.changeset.view', args=(1,))) 287 | self.assertEquals(rv.status_code, 302) 288 | rv = cl.get(reverse('softdelete.changeset.undelete', args=(1,))) 289 | self.assertEquals(rv.status_code, 302) 290 | 291 | 292 | class UndeleteTest(BaseTest): 293 | def pre_undelete(self, *args, **kwargs): 294 | self.pre_undelete_called = True 295 | 296 | def post_undelete(self, *args, **kwargs): 297 | self.post_undelete_called = True 298 | 299 | def test_undelete(self): 300 | self.pre_undelete_called = False 301 | self.post_undelete_called = False 302 | pre_undelete.connect(self.pre_undelete) 303 | post_undelete.connect(self.post_undelete) 304 | self.assertFalse(self.pre_undelete_called) 305 | self.assertFalse(self.post_undelete_called) 306 | self.cs_count = ChangeSet.objects.count() 307 | self.rs_count = SoftDeleteRecord.objects.count() 308 | self.tmo1.delete() 309 | self.assertEquals(self.cs_count + 1, ChangeSet.objects.count()) 310 | self.assertEquals(self.rs_count + (TEST_MODEL_THREE_COUNT // 2 # half is assigned by tmo1 311 | + TEST_MODEL_TWO_CASCADE_COUNT // 2 # half assigned to cascade model two 312 | + 1 # tmo1 itself 313 | ), SoftDeleteRecord.objects.count()) 314 | self.tmo1 = TestModelOne.objects.get(pk=self.tmo1.pk) 315 | self.tmo2 = TestModelOne.objects.get(pk=self.tmo2.pk) 316 | self.assertTrue(self.tmo1.deleted) 317 | self.assertFalse(self.tmo2.deleted) 318 | self.tmo1.undelete() 319 | self.assertEquals(0, ChangeSet.objects.count()) 320 | self.assertEquals(0, SoftDeleteRecord.objects.count()) 321 | self.tmo1 = TestModelOne.objects.get(pk=self.tmo1.pk) 322 | self.tmo2 = TestModelOne.objects.get(pk=self.tmo2.pk) 323 | self.assertFalse(self.tmo1.deleted) 324 | self.assertFalse(self.tmo2.deleted) 325 | self.assertTrue(self.pre_undelete_called) 326 | self.assertTrue(self.post_undelete_called) 327 | 328 | self.tmo1.delete() 329 | self.assertEquals(self.cs_count + 1, ChangeSet.objects.count()) 330 | self.assertEquals(self.rs_count + (TEST_MODEL_THREE_COUNT // 2 # half is assigned by tmo1 331 | + TEST_MODEL_TWO_CASCADE_COUNT // 2 # half assigned to cascade model two 332 | + 1 # tmo1 itself 333 | ), SoftDeleteRecord.objects.count()) 334 | self.tmo1 = TestModelOne.objects.get(pk=self.tmo1.pk) 335 | self.tmo2 = TestModelOne.objects.get(pk=self.tmo2.pk) 336 | self.assertTrue(self.tmo1.deleted) 337 | self.assertFalse(self.tmo2.deleted) 338 | TestModelOne.objects.deleted_set().undelete() 339 | self.assertEquals(0, ChangeSet.objects.count()) 340 | self.assertEquals(0, SoftDeleteRecord.objects.count()) 341 | self.tmo1 = TestModelOne.objects.get(pk=self.tmo1.pk) 342 | self.tmo2 = TestModelOne.objects.get(pk=self.tmo2.pk) 343 | self.assertFalse(self.tmo1.deleted) 344 | self.assertFalse(self.tmo2.deleted) 345 | self.assertTrue(self.pre_undelete_called) 346 | self.assertTrue(self.post_undelete_called) 347 | 348 | 349 | class M2MTests(BaseTest): 350 | def test_m2mdelete(self): 351 | t3 = TestModelThree.objects.all()[0] 352 | self.assertFalse(t3.deleted) 353 | for x in t3.tmos.all(): 354 | self.assertFalse(x.deleted) 355 | t3.delete() 356 | for x in t3.tmos.all(): 357 | self.assertFalse(x.deleted) 358 | 359 | 360 | class SoftDeleteRelatedFieldLookupsTests(BaseTest): 361 | def test_related_foreign_key(self): 362 | tmt1 = TestModelTwoCascade.objects.create(extra_int=100, tmo=self.tmo1) 363 | tmt2 = TestModelTwoCascade.objects.create(extra_int=100, tmo=self.tmo2) 364 | 365 | self.assertEquals(self.tmo1.tmts.filter(extra_int=100).count(), 1) 366 | self.assertEquals(self.tmo1.tmts.filter(extra_int=100)[0].pk, tmt1.pk) 367 | self.assertEquals(self.tmo2.tmts.filter(extra_int=100).count(), 1) 368 | self.assertEquals(self.tmo2.tmts.filter(extra_int=100)[0].pk, tmt2.pk) 369 | 370 | self.assertEquals(self.tmo1.tmts.get(extra_int=100), tmt1) 371 | self.assertEquals(self.tmo2.tmts.get(extra_int=100), tmt2) 372 | 373 | tmt1.delete() 374 | self.assertEquals(self.tmo1.tmts.filter(extra_int=100).count(), 0) 375 | tmt1.undelete() 376 | self.assertEquals(self.tmo1.tmts.filter(extra_int=100).count(), 1) 377 | 378 | tmt1.delete() 379 | tmt1.delete() 380 | self.assertRaises(TestModelTwoCascade.DoesNotExist, 381 | self.tmo1.tmts.get, extra_int=100) 382 | 383 | def test_related_m2m(self): 384 | t31 = TestModelThree.objects.create(extra_int=100) 385 | TestModelThrough.objects.create(tmo1=self.tmo1, tmo3=t31) 386 | t32 = TestModelThree.objects.create(extra_int=100) 387 | TestModelThrough.objects.create(tmo1=self.tmo2, tmo3=t32) 388 | 389 | self.assertEquals(self.tmo1.testmodelthree_set.filter(extra_int=100).count(), 1) 390 | self.assertEquals(self.tmo1.testmodelthree_set.filter(extra_int=100)[0].pk, t31.pk) 391 | self.assertEquals(self.tmo2.testmodelthree_set.filter(extra_int=100).count(), 1) 392 | self.assertEquals(self.tmo2.testmodelthree_set.filter(extra_int=100)[0].pk, t32.pk) 393 | 394 | self.assertEquals(self.tmo1.testmodelthree_set.get(extra_int=100), t31) 395 | self.assertEquals(self.tmo2.testmodelthree_set.get(extra_int=100), t32) 396 | 397 | t31.delete() 398 | self.assertEquals(self.tmo1.testmodelthree_set.filter(extra_int=100).count(), 0) 399 | t31.undelete() 400 | self.assertEquals(self.tmo1.testmodelthree_set.filter(extra_int=100).count(), 1) 401 | 402 | t31.delete() 403 | t31.delete() 404 | self.assertRaises(TestModelThree.DoesNotExist, 405 | self.tmo1.testmodelthree_set.get, extra_int=100) 406 | 407 | def test_one_to_one(self): 408 | bob = TestModelBaseO2OMale.objects.create(name='Bob') 409 | alice = TestModelO2OFemaleSetNull.objects.create(name='Alice', link=bob) 410 | 411 | bob.delete() 412 | 413 | self.assertEquals(alice.link_id, None) 414 | 415 | romeo = TestModelBaseO2OMale.objects.create(name='Romeo') 416 | juliet = TestModelO2OFemaleCascade.objects.create(name='Juliet', link=romeo) 417 | 418 | romeo.delete() 419 | 420 | self.assertRaises(TestModelO2OFemaleCascade.DoesNotExist, TestModelO2OFemaleCascade.objects.get, name='Juliet') 421 | self.assertEquals(juliet.deleted, True) 422 | 423 | kurt = TestModelBaseO2OMale.objects.create(name='Kurt') 424 | courtney = TestModelO2OFemaleCascadeNoSD.objects.create(name='Courtney', link=kurt) 425 | jack = TestModelBaseO2OMale.objects.create(name='Jack') 426 | jill = TestModelO2OFemaleCascadeNoSD.objects.create(name='jill', link=jack) 427 | 428 | kurt.delete() 429 | 430 | self.assertTrue(TestModelO2OFemaleCascadeNoSD.objects.filter(id=jill.id).exists()) 431 | self.assertFalse(TestModelO2OFemaleCascadeNoSD.objects.filter(id=courtney.id).exists()) 432 | 433 | @override_settings(SOFTDELETE_CASCADE_ALLOW_DELETE_ALL=True) 434 | def test_fallback_delete_all_setting_false(self): 435 | bob = TestModelBaseO2OMale.objects.create(name='Bob') 436 | TestModelO2OFemaleCascadeErrorOnDelete.objects.create(name='Alice', link=bob) 437 | 438 | romeo = TestModelBaseO2OMale.objects.create(name='Romeo') 439 | TestModelO2OFemaleCascadeErrorOnDelete.objects.create(name='Juliet', link=romeo) 440 | 441 | self.assertFalse(TestModelO2OFemaleCascadeErrorOnDelete.objects.all().exists()) 442 | 443 | @override_settings(SOFTDELETE_CASCADE_ALLOW_DELETE_ALL=False) 444 | def test_fallback_delete_all_setting_false(self): 445 | bob = TestModelBaseO2OMale.objects.create(name='Bob') 446 | alice = TestModelO2OFemaleCascadeErrorOnDelete.objects.create(name='Alice', link=bob) 447 | 448 | romeo = TestModelBaseO2OMale.objects.create(name='Romeo') 449 | TestModelO2OFemaleCascadeErrorOnDelete.objects.create(name='Juliet', link=romeo) 450 | 451 | self.assertRaises(ModelDeletionException, alice.delete) 452 | self.assertTrue(TestModelO2OFemaleCascadeErrorOnDelete.objects.filter(id=alice.id).exists()) 453 | self.assertEquals(TestModelO2OFemaleCascadeErrorOnDelete.objects.count(), 2) 454 | --------------------------------------------------------------------------------