├── 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 |
8 | {%endblock%}
9 |
--------------------------------------------------------------------------------
/softdelete/templates/softdelete/changeset_form.html:
--------------------------------------------------------------------------------
1 | {%extends "softdelete/base.html"%}
2 |
3 | {%block content%}
4 |
5 |
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 |
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 [](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 |
--------------------------------------------------------------------------------